From 6d76b05821d22e4cda0c7d419d4875ad7215948a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 17:37:34 +0700 Subject: [PATCH 001/120] docs(casework): clarify CLI examples and simulation fixture Signed-off-by: Jeremi Joslin --- .../src/content/docs/configure/casework.mdx | 56 +++++++------------ .../docs/operate/casework-retention.mdx | 3 +- .../src/content/docs/operate/casework.mdx | 4 +- docs/site/src/content/docs/start/casework.mdx | 2 +- .../content/docs/tutorials/first-casework.mdx | 42 ++++---------- 5 files changed, 37 insertions(+), 70 deletions(-) diff --git a/docs/site/src/content/docs/configure/casework.mdx b/docs/site/src/content/docs/configure/casework.mdx index aaaae89d7d..dc0a35eb70 100644 --- a/docs/site/src/content/docs/configure/casework.mdx +++ b/docs/site/src/content/docs/configure/casework.mdx @@ -24,8 +24,8 @@ caseworkctl --version ``` Replace `| bash` with `| less` to read the installer before you run it on a host you operate. -`caseworkctl` prints human-readable results by default. Pass `--format json` for a JSON report on -standard output. A refused command exits nonzero and reports a diagnostic in the selected format. +`caseworkctl` prints results for a person by default and reports refusals on standard error. +Use `--format json` only when another tool needs to consume its report. {/* Evidence: crates/registry-casework/install.sh; crates/registry-caseworkctl/src/lib.rs, Command and run(). */} @@ -36,29 +36,10 @@ standard output. A refused command exits nonzero and reports a diagnostic in the The `standalone-decision` template declares a policy with no source: Casework stores the work, presents it, and records the decision itself. ```sh -caseworkctl --format json init ./decisions --template standalone-decision +caseworkctl init ./decisions --template standalone-decision ``` -```json -{ - "command": "init", - "created": [ - "casework.yaml", - "runtime.example.yaml", - "dev-clients.yaml", - "fixtures/standalone-decision.yaml", - "sources/", - ".casework/schemas/runtime.schema.json", - ".vscode/settings.json" - ], - "next": [ - "Run caseworkctl check and test, then caseworkctl dev to start a local Casework runtime, its database and its token issuer, with the directory in dev-clients.yaml already seeded." - ], - "ok": true, - "project": "./decisions", - "template": "standalone-decision" -} -``` +`init` lists the files it created and the commands to run next. `init` refuses a destination that already exists; it never overwrites a project. Point it at a new directory, or edit the project you already have. @@ -382,11 +363,11 @@ Five commands read the project and touch no network and no database, so run them | `package` | What exactly would an operator receive? | `caseworkctl check` validates the project and prints the effective policy, including the values you did not write. -For the standalone project it reports `"mode": "standalone"`, the resolved hosted kind with its schema and retention, and the inbox defaults. +For the standalone project it reports standalone mode, the resolved hosted kind with its schema and retention, and the inbox defaults. For a project that declares a source it reports the request entity, the fallback queue, the routing rule count, and the state of the imported source description, which reads `pending_source_add` before you connect the source and `checked` after. ```sh -caseworkctl --format json check ./decisions +caseworkctl check ./decisions ``` `caseworkctl explain` answers a narrower question: what the runtime enforces, with the defaults resolved and the authoring noise gone. @@ -395,6 +376,7 @@ Against a source-backed project it reports each calendar, each clock in full, an Read it when you want to confirm that what you wrote is what a team will experience. `caseworkctl simulate` runs one case at one instant. +The fixture below belongs to the [complete regional-review example](https://github.com/registrystack/registry-stack/tree/6a3bff6efcc89e0d53d0bc37ebcbe7cd62dc0c0d/products/casework/examples/multi-stage-routing-clocks), which also supplies the matching policy, source descriptions, and holiday-set revision. A simulation fixture names the source, the subject with its activity, stage, and projected fields, the moment `now`, the holiday revisions in force, and what you expect: ```yaml @@ -421,9 +403,11 @@ expect: eligibleSteps: [] ``` +If you have saved that complete example as `./regional-review`, run: + ```sh -caseworkctl simulate ./licence-casework \ - --fixture ./licence-casework/simulations/friday-review.yaml +caseworkctl simulate ./regional-review \ + --fixture ./regional-review/simulations/friday-review.yaml ``` The report names the rule that matched and the sentence behind it, the calendar and holiday revision used, the due instant in UTC, the due state, and the reminders and steps eligible at that moment. @@ -431,20 +415,20 @@ That is how you prove a five-working-day deadline entered on a Friday lands wher A simulation fixture is not a test fixture: pass a `test` fixture to `simulate` and the command refuses and names the six members a simulation accepts, `id`, `source`, `holidayRevisions`, `subject`, `now`, and `expect`. `caseworkctl test` runs the fixtures the project carries under `fixtures/`, which assert the queue and the available outcomes for a case without a source. -It prints one entry per fixture with `"status": "passed"` or the failure, so it belongs in the same loop as `check`. +It reports each fixture as passed or failed, so it belongs in the same loop as `check`. ```sh -caseworkctl --format json test ./decisions +caseworkctl test ./decisions ``` `caseworkctl package` writes the reviewed policy and the exact imported source descriptions into a new directory, which is the [policy package](../../reference/glossary/#policy-package) an operator serves. ```sh -caseworkctl --format json package ./decisions --output ./decisions-package +caseworkctl package ./decisions --output ./decisions-package ``` The output is a directory, not an archive: `casework.yaml`, every source description the policy names, and `casework.package.json`, a manifest carrying a `policyDigest` over the sorted list of path, sha256, and byte count for each file. -The report repeats the digest and states `"secretsIncluded": false` and `"runtimeConfigurationIncluded": false`, because the runtime file and its secrets stay outside the package and outside review. +The report repeats the digest and states `secretsIncluded: false` and `runtimeConfigurationIncluded: false`, because the runtime file and its secrets stay outside the package and outside review. Packaging refuses an existing output directory, so each candidate lands in its own new directory. :::caution[A packaged policy is verified by byte, not by intent] @@ -495,16 +479,16 @@ When the register carries no entity by that name, the connection refuses. Check against BReg's compiled metadata before retrying. ```sh -caseworkctl --format json source add ./tutorial-work/project \ +caseworkctl source add ./tutorial-work/project \ --project ./licence-casework --source-id professional-register ``` `source add` drives your own `bregctl` of the same Registry Stack version, requires it on `PATH` or named with `--bregctl-bin`, runs its public `check` and `explain change-requests`, and refuses a register whose selected request is not staged or not manually applied. -Without `--apply` it previews: it reports the two changes it would make to the BReg project, `/entities/scope-correction/events/casework-lifecycle-v1` and `/accessProfiles/casework-reader`, prints both candidate fragments, and ends with `"status": "preview"` and `"activation": "not_performed"`. +Without `--apply` it previews: it reports the two changes it would make to the BReg project, `/entities/scope-correction/events/casework-lifecycle-v1` and `/accessProfiles/casework-reader`, prints both candidate fragments, and reports `status: preview` and `activation: not_performed`. Read the patch before you take the next step. ```sh -caseworkctl --format json source add ./tutorial-work/project \ +caseworkctl source add ./tutorial-work/project \ --project ./licence-casework --source-id professional-register --apply ``` @@ -514,8 +498,8 @@ caseworkctl --format json source add ./tutorial-work/project \ Applying writes two owner-only files into the Casework project's `sources/` directory: `professional-register.json`, the imported description that pins the entity, its fields, its review stages, and its application mode, and `professional-register.breg-runtime.yaml`, a candidate BReg runtime binding. The binding declares an event destination pointing at the Casework receiver, with an HMAC key reference of `secret:file/breg-casework-webhook` that you provision on the BReg side. -The command writes no secret and activates nothing: its report ends with `"activation": "not_performed"` and asks you to review the binding, provision its secret, and let each product's normal launcher path activate it. -Run `caseworkctl check` again, and the source description flips to `"checked"`. +The command writes no secret and activates nothing: its report says `activation: not_performed` and asks you to review the binding, provision its secret, and let each product's normal launcher path activate it. +Run `caseworkctl check` again, and the source description changes to `checked`. Connecting a source is authoring, and serving one is deployment. Locally, `caseworkctl dev` serves a connected project only beside a running `bregctl dev` session for the registry it names: pass `--source-project tutorial-work/registry`, and the Casework session borrows that registry session's Mint as its issuer, exports each Casework client as a registry client with the same principal, and binds the source to the running registry, reconciling every five seconds. diff --git a/docs/site/src/content/docs/operate/casework-retention.mdx b/docs/site/src/content/docs/operate/casework-retention.mdx index 77c5a8bcfb..c1f98d7a19 100644 --- a/docs/site/src/content/docs/operate/casework-retention.mdx +++ b/docs/site/src/content/docs/operate/casework-retention.mdx @@ -78,7 +78,8 @@ credential are readable. Each one loads `runtime.yaml` in the project directory before applying a command. Each connects with the migration credential, not the runtime credential. Both commands preview by default and write nothing until you repeat the exact invocation with -`--apply`. The examples use `--format json` to put one report on standard output. A successful run +`--apply`. These examples use `--format json` so you can compare the exact selector, counts, and +`applied` state in the preview and apply reports. A successful run exits 0. A refusal puts `{"ok": false}` with a diagnostic on standard output and exits 1 for an invalid project or configuration, or 3 for an operational failure. Without `--format json`, Casework prints a human-readable result and sends refusal diagnostics to standard error. diff --git a/docs/site/src/content/docs/operate/casework.mdx b/docs/site/src/content/docs/operate/casework.mdx index 4a69863154..0c6a623a84 100644 --- a/docs/site/src/content/docs/operate/casework.mdx +++ b/docs/site/src/content/docs/operate/casework.mdx @@ -395,7 +395,7 @@ no source review or application authority. `caseworkctl doctor` opens every live dependency the runtime opens, without binding a listener: ```sh -caseworkctl --format json doctor --runtime-config /etc/registry-casework/runtime.yaml +caseworkctl doctor --runtime-config /etc/registry-casework/runtime.yaml ``` It checks the configuration, the exact imported source descriptions, each source connection and its @@ -404,7 +404,7 @@ one that refuses and names it. Its secret preflight resolves the audit reference anything, and a reference that cannot resolve is reported by name with the rule it broke. Directory readiness requires a team serving every queue the package declares, and a gap is reported as an instruction to complete the queue assignments as an Administrator. A run that reaches the end -prints a JSON report naming each check and each source it contacted. +prints a report naming each check and each source it contacted. {/* Evidence: crates/registry-caseworkctl/src/project.rs, doctor(), load_runtime(), and check_source_descriptions(); diff --git a/docs/site/src/content/docs/start/casework.mdx b/docs/site/src/content/docs/start/casework.mdx index 9f8619fc1f..b3d695407c 100644 --- a/docs/site/src/content/docs/start/casework.mdx +++ b/docs/site/src/content/docs/start/casework.mdx @@ -47,7 +47,7 @@ sequenceDiagram R->>C: Poll the terminal feed C-->>R: Return the outcome and an opaque actor reference V->>C: Resolve the deciding person for one outcome - Note over C: Casework sends nothing outward; the requester polls + Note over C: Casework sends nothing outward. The requester polls ``` A [hosted item](../../reference/glossary/#hosted-item) lives in Casework alone, and the policy diff --git a/docs/site/src/content/docs/tutorials/first-casework.mdx b/docs/site/src/content/docs/tutorials/first-casework.mdx index 4ee0ebf690..283ae206cd 100644 --- a/docs/site/src/content/docs/tutorials/first-casework.mdx +++ b/docs/site/src/content/docs/tutorials/first-casework.mdx @@ -67,29 +67,10 @@ Keep this terminal in the same directory for the rest of the tutorial; every pat ```sh mkdir -p tutorial-work -caseworkctl --format json init tutorial-work/casework --template standalone-decision +caseworkctl init tutorial-work/casework --template standalone-decision ``` -```json -{ - "command": "init", - "created": [ - "casework.yaml", - "runtime.example.yaml", - "dev-clients.yaml", - "fixtures/standalone-decision.yaml", - "sources/", - ".casework/schemas/runtime.schema.json", - ".vscode/settings.json" - ], - "next": [ - "Run caseworkctl check and test, then caseworkctl dev to start a local Casework runtime, its database and its token issuer, with the directory in dev-clients.yaml already seeded." - ], - "ok": true, - "project": "tutorial-work/casework", - "template": "standalone-decision" -} -``` +`init` lists the files it created and what to run next. ### What init wrote @@ -135,33 +116,34 @@ control. Nothing here is configured for anyone else's data. ::: -Start the project and keep its report: +Start the project: ```sh -caseworkctl --format json dev tutorial-work/casework | tee tutorial-work/dev-report.json +caseworkctl dev tutorial-work/casework ``` The first start downloads the pinned PostgreSQL image, so it takes longer than the later ones. -The command returns once Casework answers, with `"status": "ready"` in its report. +The command returns once Casework answers, with `status: ready` in its report. `dev` started PostgreSQL in a container, registered the four clients from `dev-clients.yaml` with Mint under a fresh key each, wrote an operator file, migrated the database, started `casework`, and seeded the [directory](../../reference/glossary/#directory) from the same clients file, which the -report confirms as `"directory": {"revision": 1, "teams": 1}`. +report confirms under `directory`. The report also names the Casework address, the Mint token endpoint, the audience, the runtime journal, and the credential file paths for each client. The services keep running after the command returns, so this one terminal is enough. -Take the two addresses and the credential directory from the report: +Use the two addresses printed in the report. These assignments use the default ports or the +`CASEWORKCTL_DEV_CASEWORK_PORT` and `CASEWORKCTL_DEV_MINT_PORT` overrides; replace the URLs if the +report differs: ```sh -casework_url=$(python3 -c 'import json; print(json.load(open("tutorial-work/dev-report.json"))["caseworkUrl"])') -token_endpoint=$(python3 -c 'import json; print(json.load(open("tutorial-work/dev-report.json"))["tokenEndpoint"])') +casework_url="http://127.0.0.1:${CASEWORKCTL_DEV_CASEWORK_PORT:-8092}" +token_endpoint="http://127.0.0.1:${CASEWORKCTL_DEV_MINT_PORT:-8093}/token" credentials=tutorial-work/casework/.casework/dev/credentials ``` -Reading the addresses from the report rather than typing them keeps the rest of the tutorial correct -on a machine where the default ports were taken and `dev` used others. +Keep this terminal open so the values remain available to the later commands. {/* Evidence: crates/registry-caseworkctl/src/dev/mod.rs, start() and report(); crates/registry-caseworkctl/src/dev/config.rs; From 8bc63182ca5445cedfac2b35f1dbe0f9a988caaa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:50 +0700 Subject: [PATCH 002/120] chore(identity): checkpoint deferred citizen federation work Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- .../registry-thunderid-tooling/src/citizen.rs | 258 ++ .../identity/thunderid/extension/README.md | 55 + .../identity/thunderid/extension/build.py | 171 ++ .../registry-citizen-federation.patch | 2322 +++++++++++++++++ .../thunderid/extension/test_build.py | 68 + 5 files changed, 2874 insertions(+) create mode 100644 crates/registry-thunderid-tooling/src/citizen.rs create mode 100644 products/identity/thunderid/extension/README.md create mode 100644 products/identity/thunderid/extension/build.py create mode 100644 products/identity/thunderid/extension/registry-citizen-federation.patch create mode 100644 products/identity/thunderid/extension/test_build.py diff --git a/crates/registry-thunderid-tooling/src/citizen.rs b/crates/registry-thunderid-tooling/src/citizen.rs new file mode 100644 index 0000000000..355e528ae1 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/citizen.rs @@ -0,0 +1,258 @@ +//! Closed native configuration for citizen authorization-code delegation. +//! Requires the reviewed native federation patch and rebuilt Gate frontend. +use crate::{ + description::{IssuerDescription, MachineClient}, + local::agent_id, + render, ToolingError, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Public registration of one governed external identity provider. +#[derive(Debug, Clone)] +pub struct Federation { + pub id: String, + pub name: String, + pub issuer: String, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub userinfo_endpoint: String, + pub jwks_endpoint: String, + /// Registered outbound OAuth client. The issuer's RS256 signing key authenticates it. + pub client_id: String, + pub redirect_uri: String, + pub id_token_alg: String, + pub userinfo_alg: String, +} + +/// The complete downstream authority shown at each fresh consent prompt. +#[derive(Debug, Clone)] +pub struct Client { + pub client_id: String, + pub name: String, + pub public_jwks: String, + pub redirect_uri: String, + /// Exact RFC 8707 resource, copied from the destination's registration export. + pub resource: String, + pub purpose: String, + pub scope_fields: BTreeMap>, + /// Optional governed eligibility constant. Never copied from citizen attributes. + pub require_active_identity: bool, +} + +fn invalid() -> ToolingError { + ToolingError::InvalidDescription { reason: "citizen federation requires exact provider URLs, a distinct code-only client, public JWKS, and bounded resource, purpose and field policy" } +} +fn bounded(s: &str, max: usize) -> bool { + !s.trim().is_empty() && s.len() <= max && !s.contains(['\r', '\n']) +} +fn endpoint(s: &str) -> Result { + let u = url::Url::parse(s).map_err(|_| invalid())?; + if u.host_str().is_none() + || !u.username().is_empty() + || u.password().is_some() + || u.fragment().is_some() + || u.query().is_some() + || !(u.scheme() == "https" + || u.scheme() == "http" + && matches!( + u.host_str(), + Some("localhost" | "127.0.0.1" | "host.docker.internal") + )) + { + return Err(invalid()); + } + Ok(u) +} + +/// Append one fixed federation, isolated human type, JIT authentication/consent +/// flow and code-only agent registration to a rendered fresh local session. +/// No arbitrary attributes, grant types, actor mappings or protocol claims are inputs. +pub fn render( + description: &IssuerDescription, + federation: &Federation, + client: &Client, +) -> Result<(), ToolingError> { + description.validate()?; + let native_id = agent_id(&description.session.id, &client.client_id); + let mut check = description.clone(); + check.machine_clients.push(MachineClient { + agent_id: native_id.clone(), + name: client.name.clone(), + description: client.name.clone(), + client_id: client.client_id.clone(), + public_jwks: client.public_jwks.clone(), + attributes: BTreeMap::new(), + token_attributes: vec![], + access_token_lifetime_seconds: 300, + token_exchange: None, + }); + check.validate()?; + let issuer = endpoint(&federation.issuer)?; + for address in [ + &federation.authorization_endpoint, + &federation.token_endpoint, + &federation.userinfo_endpoint, + &federation.jwks_endpoint, + ] { + if endpoint(address)?.origin() != issuer.origin() { + return Err(invalid()); + } + } + endpoint(&federation.redirect_uri)?; + endpoint(&client.redirect_uri)?; + if !bounded(&federation.name, 128) + || !bounded(&federation.client_id, 128) + || !bounded(&client.name, 128) + || !bounded(&client.purpose, 1024) + || !crate::description::valid_uuid(&federation.id) + || description + .exchange_issuers + .iter() + .any(|e| e.id == federation.id || e.issuer == federation.issuer) + || !["RS256", "PS256", "ES256"].contains(&federation.id_token_alg.as_str()) + || !["RS256", "PS256", "ES256"].contains(&federation.userinfo_alg.as_str()) + || client.scope_fields.is_empty() + || client.scope_fields.len() > 32 + { + return Err(invalid()); + } + let server = description + .resource_servers + .iter() + .find(|s| s.identifier == client.resource) + .ok_or_else(invalid)?; + let mut scopes = BTreeSet::new(); + for resource in &server.resources { + let mut names = vec![resource.handle.as_str()]; + let mut parent = resource.parent.as_deref(); + while let Some(handle) = parent { + if names.contains(&handle) { + return Err(invalid()); + } + names.push(handle); + parent = server + .resources + .iter() + .find(|r| r.handle == handle) + .ok_or_else(invalid)? + .parent + .as_deref(); + } + names.reverse(); + for action in &resource.actions { + scopes.insert(format!("{}:{}", names.join(":"), action.handle)); + } + } + for (scope, fields) in &client.scope_fields { + if !scopes.contains(scope) + || fields.is_empty() + || fields.len() > 64 + || fields.iter().any(|f| !bounded(f, 256)) + || fields.iter().collect::>().len() != fields.len() + { + return Err(invalid()); + } + } + let jwks: Value = serde_json::from_str(&client.public_jwks).map_err(|_| invalid())?; + for key in jwks["keys"].as_array().ok_or_else(invalid)? { + if key.get("d").is_some() + || key.get("k").is_some() + || !matches!(key["kty"].as_str(), Some("RSA" | "EC")) + || !key["kid"].as_str().is_some_and(|s| bounded(s, 128)) + { + return Err(invalid()); + } + } + let type_id = agent_id( + &description.session.id, + &format!("citizen-type:{}", federation.id), + ); + let type_name = format!("citizen-{}", &type_id[..8]); + let flow_id = agent_id( + &description.session.id, + &format!("citizen-flow:{}", client.client_id), + ); + let root = description.state_root.join(render::RESOURCES_DIR); + let bootstrap = description.state_root.join(render::BOOTSTRAP_DIR); + let connection = json!({"resource_type":"connection","id":federation.id,"name":federation.name,"type":"oidc","issuer":federation.issuer,"clientId":federation.client_id,"redirectUri":federation.redirect_uri,"authorizationEndpoint":federation.authorization_endpoint,"tokenEndpoint":federation.token_endpoint,"userInfoEndpoint":federation.userinfo_endpoint,"jwksEndpoint":federation.jwks_endpoint,"tokenEndpointAuthMethod":"private_key_jwt","idTokenSigningAlg":federation.id_token_alg,"userInfoSigningAlg":federation.userinfo_alg,"requiredClaims":["person_reference"],"scopes":["openid"],"prompt":"login","tokenExchangeEnabled":false,"idJagEnabled":false,"attributeConfiguration":{"user_type_resolution":{"default":type_name},"user_type_attribute_mappings":[{"user_type":type_name,"attributes":[{"external_attribute":"sub","local_attribute":"sub"},{"external_attribute":"person_reference","local_attribute":"person_reference"}]}]}}); + let user_type = json!({"resource_type":"user_type","id":type_id,"category":"user","name":type_name,"ouHandle":description.organization_unit.handle,"allowSelfRegistration":true,"schema":{"sub":{"type":"string","required":true},"registry_federation_issuer":{"type":"string","required":true},"person_reference":{"type":"string","required":true}}}); + let consent_input = json!({"ref":"consent_input","identifier":"consent_decisions","type":"CONSENT_INPUT","required":true}); + let flow = json!({"resource_type":"flow","id":flow_id,"name":format!("{} citizen consent",client.name),"handle":format!("citizen-{}",&flow_id[..8]),"flowType":"AUTHENTICATION","nodes":[ + {"id":"start","type":"START","onSuccess":"federation"}, + {"id":"federation","type":"TASK_EXECUTION","properties":{"idpId":federation.id,"allowAuthenticationWithoutLocalUser":true},"executor":{"name":"OIDCAuthExecutor"},"onSuccess":"provision"}, + {"id":"provision","type":"TASK_EXECUTION","condition":{"key":"{{ctx(userEligibleForProvisioning)}}","value":"true","onSkip":"consent"},"executor":{"name":"ProvisioningExecutor"},"onSuccess":"consent"}, + {"id":"consent","type":"TASK_EXECUTION","executor":{"name":"ConsentExecutor"},"onSuccess":"assert","onIncomplete":"prompt"}, + {"id":"prompt","type":"PROMPT","meta":{"components":[{"type":"TEXT","id":"heading","label":format!("{} requests your permission",client.name),"variant":"HEADING_1"},{"type":"BLOCK","id":"consent_block","components":[{"id":"consent_input","ref":"consent_decisions","type":"CONSENT_INPUT","required":true},{"type":"ACTION","id":"consent_action_deny","label":"Decline this request","variant":"SECONDARY","eventType":"SUBMIT"},{"type":"ACTION","id":"consent_action_allow","label":"Allow","variant":"PRIMARY","eventType":"SUBMIT"}]}]},"prompts":[{"inputs":[consent_input],"action":{"ref":"consent_action_allow","nextNode":"consent"}},{"inputs":[consent_input],"action":{"ref":"consent_action_deny","nextNode":"consent"}}]}, + {"id":"assert","type":"TASK_EXECUTION","executor":{"name":"AuthAssertExecutor"},"onSuccess":"end"},{"id":"end","type":"END"}]}); + let mut policy = json!({"purpose":client.purpose,"resource":client.resource,"scopeFields":client.scope_fields,"subjectAttribute":"person_reference"}); + if client.require_active_identity { + policy["identityStatus"] = json!("active"); + } + let agent = json!({"resource_type":"agent","id":native_id,"type":"default","ouHandle":description.organization_unit.handle,"name":client.name,"authFlowId":flow_id,"allowedUserTypes":[type_name],"assertion":{"validityPeriod":300,"userAttributes":["person_reference"]},"loginConsent":{"validityPeriod":300,"delegation":policy},"inboundAuthConfig":[{"type":"oauth2","config":{"clientId":client.client_id,"grantTypes":["authorization_code"],"responseTypes":["code"],"redirectUris":[client.redirect_uri],"pkceRequired":true,"publicClient":false,"tokenEndpointAuthMethod":"private_key_jwt","certificate":{"type":"JWKS","value":client.public_jwks},"token":{"accessToken":{"userConfig":{"validityPeriod":300,"attributes":["person_reference"]}}}}}]}); + for (path, doc) in [ + ( + root.join("connections") + .join(format!("{}.yaml", federation.id)), + connection, + ), + ( + bootstrap.join("user-types").join(format!("{type_id}.yaml")), + user_type, + ), + ( + bootstrap.join("flows").join(format!("{flow_id}.yaml")), + flow, + ), + ( + bootstrap.join("agents").join(format!("{native_id}.yaml")), + agent, + ), + ] { + render::write_owner_only( + &path, + serde_norway::to_string(&doc) + .map_err(|_| invalid())? + .as_bytes(), + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn citizen_configuration_rejects_authority_and_transport_expansion_before_writing() { + let description = crate::testing::synthetic_description(); + let mut provider = Federation { + id: "0197aaaa-0000-7000-8000-0000000000d9".into(), + name: "Synthetic provider".into(), + issuer: "https://provider.example".into(), + authorization_endpoint: "https://provider.example/authorize".into(), + token_endpoint: "https://provider.example/token".into(), + userinfo_endpoint: "https://provider.example/userinfo".into(), + jwks_endpoint: "https://provider.example/jwks".into(), + client_id: "outbound".into(), + redirect_uri: "http://127.0.0.1:8090/gate/signin".into(), + id_token_alg: "PS256".into(), + userinfo_alg: "PS256".into(), + }; + let client = Client { + client_id: "citizen".into(), + name: "Citizen agent".into(), + public_jwks: description.machine_clients[0].public_jwks.clone(), + redirect_uri: "https://agent.example/callback".into(), + resource: description.resource_servers[0].identifier.clone(), + purpose: "Status lookup".into(), + scope_fields: BTreeMap::from([("unregistered:scope".into(), vec!["status".into()])]), + require_active_identity: true, + }; + assert!(render(&description, &provider, &client).is_err()); + provider.token_endpoint = "https://other.example/token".into(); + assert!(render(&description, &provider, &client).is_err()); + provider.token_endpoint = "https://provider.example/token".into(); + provider.userinfo_alg = "none".into(); + assert!(render(&description, &provider, &client).is_err()); + } +} diff --git a/products/identity/thunderid/extension/README.md b/products/identity/thunderid/extension/README.md new file mode 100644 index 0000000000..8d43af569d --- /dev/null +++ b/products/identity/thunderid/extension/README.md @@ -0,0 +1,55 @@ +# Native citizen federation + +Institutional RFC 8693 exchange uses the pinned upstream issuer. Citizen delegation additionally requires the source patch in this directory and the rebuilt native Gate frontend. The patch adds outbound private-key JWT authentication, S256 PKCE, essential claims, strict signed OIDC response validation, and consent-bound citizen authorization codes inside ThunderID. It does not add another issuer service or a custom delegation grant. + +## Build and verify + +Run from the Registry Stack root with Docker, Go and the upstream-pinned pnpm version available: + +```sh +python3 products/identity/thunderid/extension/build.py --output /absolute/fresh/build --image +``` + +Use `--archive /path/to/cached.tar.gz` to reuse a download. The builder verifies the archive against `crates/registry-thunderid-tooling/thunderid-version.json`, applies a captured patch, builds the native server and Gate, and creates a new local candidate image. It refuses existing output directories. `build.json` records the upstream commit, archive and patch checksums, binary and Gate asset checksums, frontend lockfile checksum, tool versions, base image digest and candidate image ID. No service is started. Use that immutable image ID in the owning session's `container::Session.image`. + +The patch carries focused native Go tests with real local authorization, token, JWKS and signed UserInfo endpoints. In the prepared `upstream/backend` directory, run: + +```sh +go test -mod=readonly ./internal/authn/oauth ./internal/authn/oidc ./internal/authn/consent ./internal/connection ./internal/idp ./internal/flow/executor ./internal/oauth/oauth2/granthandlers ./internal/oauth/oauth2/authz ./internal/oauth/oauth2/token ./pkg/thunderidengine/providers +``` + +In `upstream/frontend/packages/design`, run: + +```sh +pnpm exec vitest run src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx src/components/flow/__tests__/FlowComponentRenderer.test.tsx +``` + +Run the institutional regression against the candidate using `products/identity/scripts/test-contextual-exchange.py --image sha256:LOCAL_IMAGE_ID`. Its candidate mode also bootstraps the native citizen connection, user type, flow and code-only client. This establishes configuration acceptance and institutional interoperability; a deployment must separately verify its real identity provider and destination journey. + +## Governed configuration + +`registry_thunderid_tooling::citizen::render` appends a closed native citizen registration to a fresh session after `render::render` and before `local::start`. Supply the provider's exact issuer, authorization/token/UserInfo/JWKS endpoints, registered outbound client ID and redirect URI, expected signed response algorithms, and the agent's own public JWKS and exact callback URI. The helper emits a separate human type and a federation, JIT provisioning, consent and assertion flow. Provider identity is bound to the verified issuer and pairwise `sub`; account linking by mutable claims is disabled on this path. + +The provider must supply a governed opaque `person_reference` in signed UserInfo. It remains distinct from OIDC `sub`. Only the consent-approved verified value can become `registry_subject_person_reference`. Optional `require_active_identity` emits the governed constant `registry_identity_status=active`. It never reads this constant from citizen input. Scope-to-field descriptions, purpose and the exact destination resource come from the owning product's authorization policy. Copy a development resource from that session's client export; do not derive it from a project name. + +The native policy shape is: + +```yaml +loginConsent: + validityPeriod: 300 + delegation: + purpose: citizen-self-service + resource: urn:destination:exact-registration + scopeFields: + registry:population:self-service: [person-reference, status] + subjectAttribute: person_reference + identityStatus: active +``` + +Citizen agents admit only `authorization_code`, exact redirects, PKCE and at most 300-second access tokens. Every authorization requires fresh consent. Tokens carry the citizen subject, authenticated OAuth `client_id`, native registered agent entity ID in `act.sub`, `registry_actor_kind=agent`, and governed purpose. They cannot carry institutional grant attributes. Code redemption checks the exact current consent authorization record and unchanged resource, scopes, field/purpose policy and duration. Missing cache, unavailable consent, withdrawal, expiry or replacement approval denies old codes. + +## Citizen control and provider keys + +The agent's normal authorization link opens the native Gate flow. After authenticating with the configured identity provider, the citizen sees the agent, destination, purpose, requested fields and duration. **Decline this request** denies this issuance and preserves earlier consent. **Withdraw this agent’s access** withdraws that authenticated citizen's current consent for this agent and invalidates outstanding codes. Reapproving later does not revive old codes. Already issued access tokens may remain usable until their expiry, at most five minutes. Withdrawal is available in the native human flow without requiring the agent to return a bearer token. + +Outbound `private_key_jwt` reuses the injected issuer signing service, with RS256, the registered signing `kid`, exact token-endpoint audience, fresh `jti`, and 60-second assertions. Register its public key with the identity provider. Rotating that issuer key also requires updating the provider's outbound client registration. Endpoint origins must match the exact configured issuer; redirects are refused. HTTPS is required except explicitly authored local HTTP fixtures. ID Tokens and UserInfo must be signed JWS with the configured algorithms, matching issuer, audience, key and subject; ID Token nonce and both authorization transactions' state/PKCE bindings are verified. Encrypted UserInfo is not enabled by this profile. diff --git a/products/identity/thunderid/extension/build.py b/products/identity/thunderid/extension/build.py new file mode 100644 index 0000000000..2914207117 --- /dev/null +++ b/products/identity/thunderid/extension/build.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Build the Registry ThunderID extension from verified upstream source. + +Only creates a fresh output directory. Never replaces an existing build or +starts a service. Use --image to build a local candidate container as well. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import urllib.request + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[3] +PIN = ROOT / "crates/registry-thunderid-tooling/thunderid-version.json" + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def tree_sha256(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise ValueError("build assets must not contain symlinks") + if path.is_file(): + digest.update(path.relative_to(root).as_posix().encode() + b"\0") + digest.update(bytes.fromhex(sha256(path))) + return digest.hexdigest() + + +def extract_source(archive: Path, destination: Path, expected_digest: str) -> None: + if sha256(archive) != expected_digest: + raise ValueError("upstream archive checksum mismatch") + # Verify all paths before extraction; create internal symlinks last. + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + roots = {Path(member.name).parts[0] for member in members if member.name} + if len(roots) != 1: + raise ValueError("upstream archive must contain one source root") + for member in members: + path = Path(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile() or member.issym()): + raise ValueError("unsupported upstream archive entry") + if member.issym(): + relative = Path(*path.parts[1:]) + resolved = (destination / relative.parent / member.linkname).resolve() + if Path(member.linkname).is_absolute() or not resolved.is_relative_to(destination.resolve()): + raise ValueError("unsupported upstream archive link") + destination.mkdir() + for member in members: + if member.issym(): + continue + relative = Path(*Path(member.name).parts[1:]) + target = destination / relative + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with source.extractfile(member) as data, target.open("xb") as output: + shutil.copyfileobj(data, output) + target.chmod(member.mode & 0o755) + for member in members: + if member.issym(): + target = destination / Path(*Path(member.name).parts[1:]) + target.parent.mkdir(parents=True, exist_ok=True) + target.symlink_to(member.linkname) + + +def run(arguments: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None: + subprocess.run(arguments, cwd=cwd, env=env, check=True) + + +def apply_source_patch(source: Path, patch: Path) -> None: + # Without a local Git root, git apply can discover the enclosing Registry + # repository and silently skip paths outside the current subdirectory. + run(["git", "init", "--quiet"], cwd=source) + run(["git", "apply", "--check", str(patch)], cwd=source) + run(["git", "apply", str(patch)], cwd=source) + run(["git", "apply", "--reverse", "--check", str(patch)], cwd=source) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--archive", type=Path, help="cached source archive, verified against the pin") + parser.add_argument("--output", type=Path, required=True, help="fresh build directory") + parser.add_argument("--prepare-only", action="store_true") + parser.add_argument("--image", action="store_true", help="build a local image for this host's Docker architecture") + args = parser.parse_args() + if args.prepare_only and args.image: + parser.error("--prepare-only and --image cannot be combined") + output = args.output.resolve() + if output.exists(): + parser.error("output already exists; choose a fresh directory") + pin = json.loads(PIN.read_text()) + output.mkdir(parents=True) + archive = args.archive.resolve() if args.archive else output / "upstream.tar.gz" + if not args.archive: + urllib.request.urlretrieve(pin["source"]["archiveUrl"], archive) + source = output / "upstream" + extract_source(archive, source, pin["source"]["archiveSha256"]) + patch = HERE / "registry-citizen-federation.patch" + # Apply a captured copy so the metadata always describes the applied bytes. + shutil.copyfile(patch, output / "thunderid.patch") + apply_source_patch(source, output / "thunderid.patch") + metadata = { + "schema": "registry.thunderid-extension-build/v1", + "upstreamVersion": pin["version"], + "upstreamCommit": pin["source"]["commit"], + "archiveSha256": pin["source"]["archiveSha256"], + "patchSha256": sha256(output / "thunderid.patch"), + "baseImage": pin["image"], + } + if not args.prepare_only: + environment = os.environ.copy() + if args.image: + architecture = subprocess.check_output( + ["docker", "info", "--format", "{{.Architecture}}"], text=True + ).strip() + go_arch = {"aarch64": "arm64", "arm64": "arm64", "x86_64": "amd64", "amd64": "amd64"}.get(architecture) + if go_arch is None: + raise ValueError("supported Docker architectures are amd64 and arm64") + environment.update(GOOS="linux", GOARCH=go_arch, CGO_ENABLED="0") + binary = output / "thunderid" + run(["go", "build", "-mod=readonly", "-trimpath", "-o", str(binary), "./cmd/server"], + cwd=source / "backend", env=environment) + metadata["binarySha256"] = sha256(binary) + metadata["goVersion"] = subprocess.check_output(["go", "version"], text=True).strip() + frontend = source / "frontend" + package = json.loads((source / "package.json").read_text()) + expected_pnpm = package["devEngines"]["packageManager"]["version"] + actual_pnpm = subprocess.check_output(["pnpm", "--version"], cwd=frontend, text=True).strip() + if actual_pnpm != expected_pnpm: + raise ValueError("pnpm version does not match the pinned upstream package manager") + run(["pnpm", "install", "--frozen-lockfile"], cwd=frontend) + run(["pnpm", "exec", "turbo", "run", "build", "--filter=@thunderid/gate..."], cwd=frontend) + gate = frontend / "apps/gate/dist" + if not (gate / "index.html").is_file(): + raise ValueError("native Gate frontend build is missing") + metadata["gateTreeSha256"] = tree_sha256(gate) + metadata["frontendLockSha256"] = sha256(source / "pnpm-lock.yaml") + metadata["nodeVersion"] = subprocess.check_output(["node", "--version"], text=True).strip() + metadata["pnpmVersion"] = actual_pnpm + if args.image: + context = output / "image" + context.mkdir() + shutil.copyfile(binary, context / "thunderid") + shutil.copytree(gate, context / "gate") + (context / "Dockerfile").write_text( + f"FROM {pin['image']}\n" + "COPY --chmod=0755 --chown=10001:10001 thunderid /opt/thunderid/thunderid\n" + "USER root\nRUN rm -rf /opt/thunderid/apps/gate\n" + "COPY --chown=10001:10001 gate /opt/thunderid/apps/gate\nUSER 10001\n" + ) + run(["docker", "build", "--iidfile", str(output / "image-id"), str(context)], cwd=output) + metadata["imageId"] = (output / "image-id").read_text().strip() + (output / "build.json").write_text(json.dumps(metadata, indent=2) + "\n") + print(output / "build.json") + + +if __name__ == "__main__": + main() diff --git a/products/identity/thunderid/extension/registry-citizen-federation.patch b/products/identity/thunderid/extension/registry-citizen-federation.patch new file mode 100644 index 0000000000..5d4faf1e5d --- /dev/null +++ b/products/identity/thunderid/extension/registry-citizen-federation.patch @@ -0,0 +1,2322 @@ +diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go +--- a/backend/cmd/server/servicemanager.go ++++ b/backend/cmd/server/servicemanager.go +@@ -478,7 +478,7 @@ + tokenValidator, err := oauth.Initialize(mux, actorProvider, authnProvider, jwtService, jweService, + flowExecService, observabilitySvc, runtimeCryptoSvc, ouService, attributeCacheService, authZService, + resourceServerProvider, i18nService, idpService, dpopVerifier, +- runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, oauthCfg) ++ runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, oauthCfg, consentEnforcer) + fatalOnError(ctx, logger, err, "Failed to initialize OAuth services") + + // Initialized after the OAuth services because credential issuance validates the presented +diff --git a/backend/internal/authn/common/model.go b/backend/internal/authn/common/model.go +--- a/backend/internal/authn/common/model.go ++++ b/backend/internal/authn/common/model.go +@@ -63,7 +63,8 @@ + // AuthorizationData holds authorization flow parameters exchanged during federated authentication. + type AuthorizationData struct { + // Code is the authorization code received from the identity provider +- Code string ++ CodeVerifier string ++ Code string + // Nonce is the nonce parameter received from the identity provider (if applicable) + Nonce string + } +diff --git a/backend/internal/authn/consent/delegation.go b/backend/internal/authn/consent/delegation.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/authn/consent/delegation.go +@@ -0,0 +1,98 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package consent ++ ++import ( ++ "context" ++ "github.com/thunder-id/thunderid/internal/consent" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++ "time" ++) ++ ++// ValidateConsentAuthorization reads current native consent at every code ++// redemption. A replacement authorization row can never revive an older code. ++func (s *consentEnforcerService) ValidateConsentAuthorization(ctx context.Context, b providers.DelegationConsentBinding) *tidcommon.ServiceError { ++ if s.consentService == nil || b.ConsentID == "" || b.AuthorizationID == "" || b.UserID == "" || b.AppID == "" || b.Deadline <= time.Now().Unix() { ++ return &ErrorConsentSessionInvalid ++ } ++ records, err := s.consentService.SearchConsents(ctx, consent.ConsentFilter{GroupID: b.AppID, UserID: b.UserID, ConsentStatus: consent.ConsentStatusActive}) ++ if err != nil { ++ return &ErrorConsentSearchFailed ++ } ++ for _, record := range records { ++ if record == nil || record.ID != b.ConsentID || record.GroupID != b.AppID || record.Status != consent.ConsentStatusActive || record.ValidityTime < b.Deadline { ++ continue ++ } ++ authorized := false ++ for _, a := range record.Authorizations { ++ if a.ID == b.AuthorizationID && a.UserID == b.UserID && a.Status == consent.AuthorizationStatusApproved { ++ authorized = true ++ } ++ } ++ if !authorized { ++ continue ++ } ++ approved := map[string]map[string]bool{} ++ for _, purpose := range record.Purposes { ++ elements := map[string]bool{} ++ for _, element := range purpose.Elements { ++ elements[element.Name] = element.IsUserApproved ++ } ++ approved[purpose.Name] = elements ++ } ++ valid := true ++ for _, scope := range b.Scopes { ++ if !approved[consent.PermissionPurposeName(b.AppID)][scope] { ++ valid = false ++ } ++ } ++ for _, attr := range b.Attributes { ++ if !approved[consent.AttributePurposeName(b.AppID)][attr] { ++ valid = false ++ } ++ } ++ if valid { ++ return nil ++ } ++ } ++ return &ErrorConsentSessionInvalid ++} ++ ++// WithdrawAuthenticatedConsent is called only after the interactive flow has ++// verified a human. The caller supplies server-resolved user and application IDs. ++func (s *consentEnforcerService) WithdrawAuthenticatedConsent(ctx context.Context, appID, userID string) *tidcommon.ServiceError { ++ if s.consentService == nil || appID == "" || userID == "" { ++ return &ErrorConsentSessionInvalid ++ } ++ records, err := s.consentService.SearchConsents(ctx, consent.ConsentFilter{GroupID: appID, UserID: userID, ConsentStatus: consent.ConsentStatusActive}) ++ if err != nil { ++ return &ErrorConsentSearchFailed ++ } ++ for _, record := range records { ++ if record == nil || record.GroupID != appID { ++ return &ErrorConsentSessionInvalid ++ } ++ for _, authorization := range record.Authorizations { ++ if authorization.UserID != userID { ++ return &ErrorConsentSessionInvalid ++ } ++ } ++ purposes := make([]consent.ConsentPurposeItem, 0, len(record.Purposes)) ++ for _, purpose := range record.Purposes { ++ denied := consent.ConsentPurposeItem{Name: purpose.Name} ++ for _, element := range purpose.Elements { ++ element.IsUserApproved = false ++ denied.Elements = append(denied.Elements, element) ++ } ++ purposes = append(purposes, denied) ++ } ++ _, err := s.consentService.UpdateConsent(ctx, record.ID, &consent.ConsentRequest{GroupID: appID, ValidityTime: time.Now().Unix(), Purposes: purposes, ++ Authorizations: []consent.ConsentAuthorizationRequest{{UserID: userID, Type: consent.AuthorizationTypeAuthorization, Status: consent.AuthorizationStatusRejected}}}) ++ if err != nil { ++ return &ErrorConsentSessionInvalid ++ } ++ } ++ return nil ++} +diff --git a/backend/internal/authn/consent/delegation_test.go b/backend/internal/authn/consent/delegation_test.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/authn/consent/delegation_test.go +@@ -0,0 +1,87 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++package consent ++ ++import ( ++ "context" ++ "github.com/stretchr/testify/mock" ++ "github.com/stretchr/testify/require" ++ "github.com/thunder-id/thunderid/internal/consent" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++ "github.com/thunder-id/thunderid/tests/mocks/consentmock" ++ "testing" ++ "time" ++) ++ ++func TestCitizenCodeWithdrawalCannotBeUndoneByReapproval(t *testing.T) { ++ binding := providers.DelegationConsentBinding{ConsentID: "consent", AuthorizationID: "approval-one", AppID: "agent", UserID: "citizen", Deadline: time.Now().Unix() + 120, Scopes: []string{"records:read"}, Attributes: []string{"person_reference"}} ++ record := &consent.Consent{ID: "consent", GroupID: "agent", Status: consent.ConsentStatusActive, ValidityTime: binding.Deadline, ++ Authorizations: []consent.ConsentAuthorization{{ID: "approval-one", UserID: "citizen", Status: consent.AuthorizationStatusApproved}}, ++ Purposes: []consent.ConsentPurposeItem{{Name: "permissions:agent", Elements: []consent.ConsentElementApproval{{Name: "records:read", IsUserApproved: true}}}, {Name: "attributes:agent", Elements: []consent.ConsentElementApproval{{Name: "person_reference", IsUserApproved: true}}}}} ++ store := consentmock.NewConsentServiceInterfaceMock(t) ++ unavailable := false ++ store.On("SearchConsents", mock.Anything, consent.ConsentFilter{GroupID: "agent", UserID: "citizen", ConsentStatus: consent.ConsentStatusActive}).Return( ++ func(context.Context, consent.ConsentFilter) []*consent.Consent { return []*consent.Consent{record} }, ++ func(context.Context, consent.ConsentFilter) *tidcommon.ServiceError { ++ if unavailable { ++ return &tidcommon.InternalServerError ++ } ++ return nil ++ }) ++ svc := &consentEnforcerService{consentService: store} ++ require.Nil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ record.Authorizations[0].Status = consent.AuthorizationStatusRejected ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ record.Authorizations[0].Status = consent.AuthorizationStatusApproved ++ record.Authorizations[0].ID = "approval-two" ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding), "reapproval revived an old code") ++ binding.AuthorizationID = "approval-two" ++ require.Nil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ record.Purposes[0].Elements[0].IsUserApproved = false ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ record.Purposes[0].Elements[0].IsUserApproved = true ++ record.Purposes[1].Elements[0].IsUserApproved = false ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ record.Purposes[1].Elements[0].IsUserApproved = true ++ unavailable = true ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++ unavailable = false ++ binding.Deadline = time.Now().Unix() - 1 ++ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) ++} ++ ++func TestAuthenticatedWithdrawalRejectsOnlyCurrentCitizenConsent(t *testing.T) { ++ for _, scenario := range []string{"ok", "other-user", "other-app", "lookup-failed", "write-failed"} { ++ t.Run(scenario, func(t *testing.T) { ++ record := &consent.Consent{ID: "current", GroupID: "agent", Authorizations: []consent.ConsentAuthorization{{ID: "old", UserID: "citizen", Status: consent.AuthorizationStatusApproved}}, Purposes: []consent.ConsentPurposeItem{{Name: "permissions:agent", Elements: []consent.ConsentElementApproval{{Name: "records:read", IsUserApproved: true}}}}} ++ if scenario == "other-user" { ++ record.Authorizations[0].UserID = "other" ++ } ++ if scenario == "other-app" { ++ record.GroupID = "other" ++ } ++ store := consentmock.NewConsentServiceInterfaceMock(t) ++ var lookupErr *tidcommon.ServiceError ++ if scenario == "lookup-failed" { ++ lookupErr = &tidcommon.InternalServerError ++ } ++ store.On("SearchConsents", mock.Anything, consent.ConsentFilter{GroupID: "agent", UserID: "citizen", ConsentStatus: consent.ConsentStatusActive}).Return([]*consent.Consent{record}, lookupErr) ++ if scenario == "ok" || scenario == "write-failed" { ++ var writeErr *tidcommon.ServiceError ++ if scenario == "write-failed" { ++ writeErr = &tidcommon.InternalServerError ++ } ++ store.On("UpdateConsent", mock.Anything, "current", mock.MatchedBy(func(req *consent.ConsentRequest) bool { ++ return req.GroupID == "agent" && req.ValidityTime <= time.Now().Unix() && len(req.Authorizations) == 1 && req.Authorizations[0].UserID == "citizen" && req.Authorizations[0].Status == consent.AuthorizationStatusRejected && !req.Purposes[0].Elements[0].IsUserApproved ++ })).Return(record, writeErr).Once() ++ } ++ err := (&consentEnforcerService{consentService: store}).WithdrawAuthenticatedConsent(context.Background(), "agent", "citizen") ++ if scenario == "ok" { ++ require.Nil(t, err) ++ } else { ++ require.NotNil(t, err) ++ } ++ }) ++ } ++} +diff --git a/backend/internal/authn/oauth/federation.go b/backend/internal/authn/oauth/federation.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/authn/oauth/federation.go +@@ -0,0 +1,69 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package oauth ++ ++import ( ++ "context" ++ "errors" ++ "net/http" ++ "net/url" ++ "slices" ++ "strings" ++ "time" ++) ++ ++type federationExchangeKey struct{} ++type federationExchange struct{ Verifier, Assertion string } ++ ++// WithFederationExchange carries server-held transaction material to the token ++// request builder. It is never an authorization request or browser parameter. ++func WithFederationExchange(ctx context.Context, verifier, assertion string) context.Context { ++ return context.WithValue(ctx, federationExchangeKey{}, federationExchange{verifier, assertion}) ++} ++ ++// FederationHTTPClient sends only to governed endpoints. It refuses redirects, ++// including same-origin redirects that could change the assertion audience. ++func FederationHTTPClient() *http.Client { ++ return &http.Client{Timeout: 15 * time.Second, ++ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} ++} ++ ++// ValidateFederation keeps private_key_jwt connections complete and explicit. ++// Loopback HTTP is only for locally authored protocol fixtures. ++func (c *OAuthClientConfig) ValidateFederation() error { ++ if c.TokenEndpointAuthMethod == "" { ++ return nil ++ } ++ invalid := errors.New("private_key_jwt federation requires exact issuer endpoints, signed responses, and required claims") ++ if c.TokenEndpointAuthMethod != "private_key_jwt" || c.ClientSecret != "" || c.ClientID == "" || ++ c.RedirectURI == "" || !slices.Contains(c.Scopes, "openid") || len(c.RequiredClaims) == 0 || len(c.RequiredClaims) > 32 { ++ return invalid ++ } ++ for _, alg := range []string{c.IDTokenSigningAlg, c.UserInfoSigningAlg} { ++ if !slices.Contains([]string{"RS256", "PS256", "ES256"}, alg) { ++ return invalid ++ } ++ } ++ issuer, err := url.Parse(c.Issuer) ++ if err != nil || issuer.Host == "" || issuer.User != nil || issuer.RawQuery != "" || issuer.Fragment != "" { ++ return invalid ++ } ++ if issuer.Scheme != "https" && !(issuer.Scheme == "http" && slices.Contains([]string{"localhost", "127.0.0.1", "::1", "host.docker.internal"}, issuer.Hostname())) { ++ return invalid ++ } ++ for _, endpoint := range []string{c.OAuthEndpoints.AuthorizationEndpoint, c.OAuthEndpoints.TokenEndpoint, c.OAuthEndpoints.UserInfoEndpoint, c.OAuthEndpoints.JwksEndpoint} { ++ parsed, err := url.Parse(endpoint) ++ if err != nil || parsed.Scheme != issuer.Scheme || parsed.Host != issuer.Host || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { ++ return invalid ++ } ++ } ++ seen := map[string]bool{} ++ for _, claim := range c.RequiredClaims { ++ if claim == "" || len(claim) > 128 || strings.ContainsAny(claim, " \t\r\n") || seen[claim] { ++ return invalid ++ } ++ seen[claim] = true ++ } ++ return nil ++} +diff --git a/backend/internal/authn/oauth/model.go b/backend/internal/authn/oauth/model.go +--- a/backend/internal/authn/oauth/model.go ++++ b/backend/internal/authn/oauth/model.go +@@ -14,12 +14,17 @@ + + // OAuthClientConfig holds the OAuth client configuration details. + type OAuthClientConfig struct { +- ClientID string +- ClientSecret string +- RedirectURI string +- Scopes []string +- OAuthEndpoints OAuthEndpoints +- AdditionalParams map[string]string ++ Issuer string ++ TokenEndpointAuthMethod string ++ IDTokenSigningAlg string ++ UserInfoSigningAlg string ++ RequiredClaims []string ++ ClientID string ++ ClientSecret string ++ RedirectURI string ++ Scopes []string ++ OAuthEndpoints OAuthEndpoints ++ AdditionalParams map[string]string + } + + // TokenResponse represents the token endpoint response body. +diff --git a/backend/internal/authn/oauth/service.go b/backend/internal/authn/oauth/service.go +--- a/backend/internal/authn/oauth/service.go ++++ b/backend/internal/authn/oauth/service.go +@@ -174,7 +174,11 @@ + return nil, svcErr + } + +- tokenResp, svcErr := sendTokenRequest(httpReq, s.httpClient, logger) ++ client := s.httpClient ++ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { ++ client = FederationHTTPClient() ++ } ++ tokenResp, svcErr := sendTokenRequest(httpReq, client, logger) + if svcErr != nil { + return nil, svcErr + } +diff --git a/backend/internal/authn/oauth/utils.go b/backend/internal/authn/oauth/utils.go +--- a/backend/internal/authn/oauth/utils.go ++++ b/backend/internal/authn/oauth/utils.go +@@ -57,7 +57,17 @@ + oAuthClientConfig.OAuthEndpoints.UserEmailEndpoint = value + case idpPkg.PropJwksEndpoint: + oAuthClientConfig.OAuthEndpoints.JwksEndpoint = value +- case idpPkg.PropIssuer, idpPkg.PropTokenExchangeEnabled: ++ case idpPkg.PropIssuer: ++ oAuthClientConfig.Issuer = value ++ case idpPkg.PropTokenEndpointAuthMethod: ++ oAuthClientConfig.TokenEndpointAuthMethod = value ++ case idpPkg.PropIDTokenSigningAlg: ++ oAuthClientConfig.IDTokenSigningAlg = value ++ case idpPkg.PropUserInfoSigningAlg: ++ oAuthClientConfig.UserInfoSigningAlg = value ++ case idpPkg.PropRequiredClaims: ++ oAuthClientConfig.RequiredClaims = sysutils.ParseStringArray(value, ",") ++ case idpPkg.PropTokenExchangeEnabled: + // Server-side configuration consumed elsewhere (token exchange). + // These must not be forwarded as query parameters on the external authorize request. + default: +@@ -75,6 +85,9 @@ + } + } + ++ if err := oAuthClientConfig.ValidateFederation(); err != nil { ++ return nil, err ++ } + return &oAuthClientConfig, nil + } + +@@ -83,12 +96,22 @@ + *http.Request, *tidcommon.ServiceError) { + form := url.Values{} + form.Set(oauth2const.RequestParamClientID, oAuthClientConfig.ClientID) +- form.Set(oauth2const.RequestParamClientSecret, oAuthClientConfig.ClientSecret) ++ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { ++ binding, ok := ctx.Value(federationExchangeKey{}).(federationExchange) ++ if !ok || len(binding.Verifier) < 43 || binding.Assertion == "" { ++ return nil, &ErrorInvalidTokenResponse ++ } ++ form.Set("code_verifier", binding.Verifier) ++ form.Set("client_assertion", binding.Assertion) ++ form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") ++ } else { ++ form.Set(oauth2const.RequestParamClientSecret, oAuthClientConfig.ClientSecret) ++ } + form.Set(oauth2const.RequestParamRedirectURI, oAuthClientConfig.RedirectURI) + form.Set(oauth2const.RequestParamGrantType, string(providers.GrantTypeAuthorizationCode)) + form.Set(oauth2const.RequestParamCode, code) + +- httpReq, err := http.NewRequest(http.MethodPost, oAuthClientConfig.OAuthEndpoints.TokenEndpoint, ++ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, oAuthClientConfig.OAuthEndpoints.TokenEndpoint, + strings.NewReader(form.Encode())) + if err != nil { + logger.Error(ctx, "Failed to create token request", log.Error(err)) +@@ -117,14 +140,13 @@ + }() + + if resp.StatusCode != http.StatusOK { +- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + logger.Error(ctx, "Token endpoint returned an error response", +- log.Int("statusCode", resp.StatusCode), log.String("response", string(body))) ++ log.Int("statusCode", resp.StatusCode)) + return nil, &tidcommon.InternalServerError + } + + var tokenResp TokenResponse +- if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { ++ if err := json.NewDecoder(io.LimitReader(resp.Body, 256*1024)).Decode(&tokenResp); err != nil { + logger.Error(ctx, "Failed to parse token response", log.Error(err)) + return nil, &tidcommon.InternalServerError + } +@@ -135,7 +157,7 @@ + // buildUserInfoRequest constructs the HTTP request to fetch user information from the identity provider. + func buildUserInfoRequest(ctx context.Context, userInfoEndpoint string, accessToken string, logger *log.Logger) ( + *http.Request, *tidcommon.ServiceError) { +- req, err := http.NewRequest(http.MethodGet, userInfoEndpoint, nil) ++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, userInfoEndpoint, nil) + if err != nil { + logger.Error(ctx, "Failed to create userinfo request", log.Error(err)) + return nil, &tidcommon.InternalServerError +@@ -163,9 +185,8 @@ + }() + + if resp.StatusCode != http.StatusOK { +- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + logger.Error(ctx, "Userinfo endpoint returned an error response", +- log.Int("statusCode", resp.StatusCode), log.String("response", string(body))) ++ log.Int("statusCode", resp.StatusCode)) + return nil, &ErrorUserProfileRetrievalFailed + } + +diff --git a/backend/internal/authn/oidc/federation.go b/backend/internal/authn/oidc/federation.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/authn/oidc/federation.go +@@ -0,0 +1,158 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package oidc ++ ++import ( ++ "context" ++ "crypto/rand" ++ "crypto/sha256" ++ "encoding/base64" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "net/url" ++ "strings" ++ "time" ++ ++ authnoauth "github.com/thunder-id/thunderid/internal/authn/oauth" ++ "github.com/thunder-id/thunderid/internal/system/jose/jwt" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++) ++ ++const maxFederationResponse = 256 * 1024 ++ ++type federationVerifierKey struct{} ++ ++func bindPKCE(authorize string, metadata map[string]string, c *authnoauth.OAuthClientConfig) (string, error) { ++ bytes := make([]byte, 32) ++ if _, err := rand.Read(bytes); err != nil { ++ return "", err ++ } ++ verifier := base64.RawURLEncoding.EncodeToString(bytes) ++ challenge := sha256.Sum256([]byte(verifier)) ++ u, err := url.Parse(authorize) ++ if err != nil { ++ return "", err ++ } ++ q := u.Query() ++ q.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) ++ q.Set("code_challenge_method", "S256") ++ requested := map[string]interface{}{} ++ for _, claim := range c.RequiredClaims { ++ requested[claim] = map[string]bool{"essential": true} ++ } ++ claims, err := json.Marshal(map[string]interface{}{"userinfo": requested}) ++ if err != nil { ++ return "", err ++ } ++ q.Set("claims", string(claims)) ++ u.RawQuery = q.Encode() ++ metadata["code_verifier"] = verifier ++ return u.String(), nil ++} ++ ++func readFederationResponse(ctx context.Context, endpoint, bearer, accept string) ([]byte, error) { ++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) ++ if err != nil { ++ return nil, err ++ } ++ if bearer != "" { ++ req.Header.Set("Authorization", "Bearer "+bearer) ++ } ++ req.Header.Set("Accept", accept) ++ resp, err := authnoauth.FederationHTTPClient().Do(req) ++ if err != nil { ++ return nil, errors.New("federation endpoint unavailable") ++ } ++ defer resp.Body.Close() ++ if resp.StatusCode != http.StatusOK { ++ return nil, errors.New("federation endpoint refused request") ++ } ++ body, err := io.ReadAll(io.LimitReader(resp.Body, maxFederationResponse+1)) ++ if err != nil || len(body) > maxFederationResponse { ++ return nil, errors.New("federation response exceeds bound") ++ } ++ return body, nil ++} ++ ++// verifyFederationJWT fetches only the configured JWKS, never token header URLs. ++// The selected algorithm and key must agree before injected crypto verifies it. ++func (s *oidcAuthnService) verifyFederationJWT(ctx context.Context, token, alg string, c *authnoauth.OAuthClientConfig) (map[string]interface{}, *tidcommon.ServiceError) { ++ if len(token) > maxFederationResponse { ++ return nil, &ErrorInvalidIDToken ++ } ++ header, err := jwt.DecodeJWTHeader(token) ++ if err != nil || header["alg"] != alg || header["crit"] != nil || header["jku"] != nil || header["x5u"] != nil || header["b64"] != nil { ++ return nil, &ErrorInvalidIDToken ++ } ++ kid, ok := header["kid"].(string) ++ if !ok || kid == "" || len(kid) > 256 { ++ return nil, &ErrorInvalidIDToken ++ } ++ body, err := readFederationResponse(ctx, c.OAuthEndpoints.JwksEndpoint, "", "application/json") ++ if err != nil { ++ return nil, &ErrorInvalidIDToken ++ } ++ var set struct { ++ Keys []map[string]interface{} `json:"keys"` ++ } ++ if json.Unmarshal(body, &set) != nil || len(set.Keys) == 0 || len(set.Keys) > 32 { ++ return nil, &ErrorInvalidIDToken ++ } ++ var selected map[string]interface{} ++ for _, key := range set.Keys { ++ if key["kid"] != kid { ++ continue ++ } ++ if selected != nil || key["d"] != nil || (key["alg"] != nil && key["alg"] != alg) || (key["use"] != nil && key["use"] != "sig") { ++ return nil, &ErrorInvalidIDToken ++ } ++ if (strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS")) && key["kty"] != "RSA" { ++ return nil, &ErrorInvalidIDToken ++ } ++ if alg == "ES256" && (key["kty"] != "EC" || key["crv"] != "P-256") { ++ return nil, &ErrorInvalidIDToken ++ } ++ selected = key ++ } ++ if selected == nil { ++ return nil, &ErrorInvalidIDToken ++ } ++ if err := s.jwtService.VerifyJWTWithPublicKey(ctx, token, providers.KeyRef{PublicKeyJWK: selected}, c.ClientID, c.Issuer); err != nil { ++ return nil, &ErrorInvalidIDTokenSignature ++ } ++ claims, err := jwt.DecodeJWTPayload(token) ++ if err != nil { ++ return nil, &ErrorInvalidIDToken ++ } ++ sub, ok := claims["sub"].(string) ++ if !ok || sub == "" || len(sub) > 1024 { ++ return nil, &ErrorInvalidIDToken ++ } ++ // Exact single audience removes multi-audience/azp ambiguity at this boundary. ++ audOK := claims["aud"] == c.ClientID ++ if aud, ok := claims["aud"].([]interface{}); ok { ++ audOK = len(aud) == 1 && aud[0] == c.ClientID ++ } ++ if !audOK || claims["iss"] != c.Issuer { ++ return nil, &ErrorInvalidIDToken ++ } ++ if azp, exists := claims["azp"]; exists && azp != c.ClientID { ++ return nil, &ErrorInvalidIDToken ++ } ++ now := float64(time.Now().Unix()) ++ exp, ok := claims["exp"].(float64) ++ if !ok || exp <= now { ++ return nil, &ErrorInvalidIDToken ++ } ++ if iat, exists := claims["iat"]; exists { ++ value, ok := iat.(float64) ++ if !ok || value > now+30 || value >= exp { ++ return nil, &ErrorInvalidIDToken ++ } ++ } ++ return claims, nil ++} +diff --git a/backend/internal/authn/oidc/federation_test.go b/backend/internal/authn/oidc/federation_test.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/authn/oidc/federation_test.go +@@ -0,0 +1,293 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package oidc ++ ++import ( ++ "context" ++ "crypto" ++ "crypto/rand" ++ "crypto/rsa" ++ "crypto/sha256" ++ "crypto/x509" ++ "encoding/base64" ++ "encoding/json" ++ "fmt" ++ "math/big" ++ "net/http" ++ "net/http/httptest" ++ "net/url" ++ "strings" ++ "testing" ++ "time" ++ ++ "github.com/stretchr/testify/mock" ++ "github.com/stretchr/testify/require" ++ authncm "github.com/thunder-id/thunderid/internal/authn/common" ++ "github.com/thunder-id/thunderid/internal/authn/oauth" ++ "github.com/thunder-id/thunderid/internal/idp" ++ "github.com/thunder-id/thunderid/internal/system/cmodels" ++ "github.com/thunder-id/thunderid/internal/system/config" ++ joseconfig "github.com/thunder-id/thunderid/internal/system/jose/config" ++ "github.com/thunder-id/thunderid/internal/system/jose/jwt" ++ "github.com/thunder-id/thunderid/internal/system/kmprovider/defaultkm" ++ "github.com/thunder-id/thunderid/internal/system/kmprovider/defaultkm/pki" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++ "github.com/thunder-id/thunderid/tests/mocks/idp/idpmock" ++) ++ ++type protocolPKI struct { ++ pki.PKIServiceInterface ++ key *rsa.PrivateKey ++} ++ ++func (p protocolPKI) GetPrivateKey(context.Context, string) (crypto.PrivateKey, *tidcommon.ServiceError) { ++ return p.key, nil ++} ++func (p protocolPKI) GetCertificateChain(string) [][]byte { return nil } ++func (p protocolPKI) GetCertThumbprint(string) string { return "issuer-key" } ++func (p protocolPKI) GetAllX509Certificates(context.Context) (map[string]*x509.Certificate, *tidcommon.ServiceError) { ++ return map[string]*x509.Certificate{"issuer-key": {PublicKey: &p.key.PublicKey}}, nil ++} ++func (p protocolPKI) GetX509Certificate(context.Context, string) (*x509.Certificate, *tidcommon.ServiceError) { ++ return &x509.Certificate{PublicKey: &p.key.PublicKey}, nil ++} ++func (p protocolPKI) GetSupportedSigningAlgorithms() []string { return []string{"RS256", "PS256"} } ++ ++func protocolJWK(key *rsa.PrivateKey) map[string]interface{} { ++ return map[string]interface{}{"kty": "RSA", "kid": "provider-key", "alg": "PS256", "use": "sig", ++ "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())} ++} ++func protocolSign(t *testing.T, key *rsa.PrivateKey, claims map[string]interface{}, alg, kid string) string { ++ t.Helper() ++ h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid}) ++ p, _ := json.Marshal(claims) ++ input := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) ++ digest := sha256.Sum256([]byte(input)) ++ sig, err := rsa.SignPSS(rand.Reader, key, crypto.SHA256, digest[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) ++ require.NoError(t, err) ++ return input + "." + base64.RawURLEncoding.EncodeToString(sig) ++} ++ ++// TestFederationProtocol uses real local authorization, token, JWKS and UserInfo ++// endpoints and the production OAuth/JWT/default-key-manager implementations. ++// All keys and subjects are generated synthetic fixture state, never logged. ++func TestFederationProtocol(t *testing.T) { ++ config.ResetServerRuntime() ++ require.NoError(t, config.InitializeServerRuntime("", &config.Config{})) ++ issuerKey, err := rsa.GenerateKey(rand.Reader, 2048) ++ require.NoError(t, err) ++ providerKey, err := rsa.GenerateKey(rand.Reader, 2048) ++ require.NoError(t, err) ++ wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) ++ require.NoError(t, err) ++ cryptoSvc := defaultkm.NewRuntimeCryptoService(protocolPKI{key: issuerKey}, nil) ++ jwtSvc, err := jwt.Initialize(cryptoSvc, joseconfig.Config{Issuer: "urn:fixture:issuer", ValidityPeriod: 300, PreferredKeyID: "issuer-key"}) ++ require.NoError(t, err) ++ mode := "" ++ nonce := "" ++ challenge := "" ++ code := "" ++ used := false ++ counter := 0 ++ issuer := "" ++ seenJTIs := map[string]bool{} ++ tokenCalls := 0 ++ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ now := time.Now().Unix() ++ claims := map[string]interface{}{"iss": issuer, "aud": "outbound-client", "sub": "pairwise-citizen", "iat": now, "exp": now + 300, "nonce": nonce} ++ switch r.URL.Path { ++ case "/authorize": ++ q := r.URL.Query() ++ nonce = q.Get("nonce") ++ challenge = q.Get("code_challenge") ++ if q.Get("client_id") != "outbound-client" || q.Get("response_type") != "code" || q.Get("state") == "" || nonce == "" || q.Get("code_challenge_method") != "S256" || len(challenge) != 43 || q.Get("code_verifier") != "" { ++ t.Error("authorization request omitted or disclosed transaction binding") ++ w.WriteHeader(400) ++ return ++ } ++ var requested map[string]map[string]map[string]bool ++ if json.Unmarshal([]byte(q.Get("claims")), &requested) != nil || !requested["userinfo"]["person_reference"]["essential"] { ++ t.Error("essential provider-neutral claim not requested") ++ w.WriteHeader(400) ++ return ++ } ++ counter++ ++ code = fmt.Sprintf("code-%d", counter) ++ used = false ++ _ = json.NewEncoder(w).Encode(map[string]string{"code": code}) ++ case "/token": ++ tokenCalls++ ++ if mode == "token-redirect" { ++ http.Redirect(w, r, issuer+"/never", 302) ++ return ++ } ++ if r.ParseForm() != nil { ++ w.WriteHeader(400) ++ return ++ } ++ form := r.PostForm ++ d := sha256.Sum256([]byte(form.Get("code_verifier"))) ++ if used || form.Get("code") != code || base64.RawURLEncoding.EncodeToString(d[:]) != challenge { ++ w.WriteHeader(400) ++ return ++ } ++ assertion := form.Get("client_assertion") ++ parts := strings.Split(assertion, ".") ++ if len(parts) != 3 { ++ t.Error("client assertion missing") ++ w.WriteHeader(400) ++ return ++ } ++ signature, e := base64.RawURLEncoding.DecodeString(parts[2]) ++ digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) ++ if e != nil || rsa.VerifyPKCS1v15(&issuerKey.PublicKey, crypto.SHA256, digest[:], signature) != nil { ++ t.Error("client assertion signature rejected") ++ w.WriteHeader(400) ++ return ++ } ++ payload, e := jwt.DecodeJWTPayload(assertion) ++ header, he := jwt.DecodeJWTHeader(assertion) ++ jti, _ := payload["jti"].(string) ++ if e != nil || he != nil || header["alg"] != "RS256" || header["kid"] != "issuer-key" || payload["iss"] != "outbound-client" || payload["sub"] != "outbound-client" || payload["aud"] != issuer+"/token" || jti == "" || seenJTIs[jti] || payload["exp"].(float64)-payload["iat"].(float64) != 60 || form.Get("client_secret") != "" || form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { ++ t.Error("client assertion contract rejected") ++ w.WriteHeader(400) ++ return ++ } ++ seenJTIs[jti] = true ++ used = true ++ key := providerKey ++ alg := "PS256" ++ kid := "provider-key" ++ switch mode { ++ case "id-wrong-issuer": ++ claims["iss"] = "urn:other" ++ case "id-wrong-audience": ++ claims["aud"] = "other-client" ++ case "id-multi-audience": ++ claims["aud"] = []string{"outbound-client", "other-client"} ++ case "id-wrong-azp": ++ claims["azp"] = "other-client" ++ case "id-no-sub": ++ delete(claims, "sub") ++ case "id-expired": ++ claims["exp"] = now - 1 ++ case "id-future": ++ claims["iat"] = now + 120 ++ case "id-wrong-nonce": ++ claims["nonce"] = "other-transaction" ++ case "id-wrong-key": ++ key = wrongKey ++ case "id-wrong-alg": ++ alg = "RS256" ++ case "id-unknown-kid": ++ kid = "unknown" ++ } ++ _ = json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "fixture-access", "token_type": "Bearer", "id_token": protocolSign(t, key, claims, alg, kid), "expires_in": 300}) ++ case "/jwks": ++ if mode == "jwks-redirect" { ++ http.Redirect(w, r, issuer+"/never", 302) ++ return ++ } ++ keys := []map[string]interface{}{protocolJWK(providerKey)} ++ if mode == "duplicate-kid" { ++ keys = append(keys, protocolJWK(wrongKey)) ++ } ++ if mode == "jwks-wrong-alg" { ++ keys[0]["alg"] = "RS256" ++ } ++ _ = json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) ++ case "/userinfo": ++ if r.Header.Get("Authorization") != "Bearer fixture-access" { ++ t.Error("userinfo access bearer missing") ++ w.WriteHeader(401) ++ return ++ } ++ if mode == "userinfo-redirect" { ++ http.Redirect(w, r, issuer+"/never", 302) ++ return ++ } ++ if mode == "userinfo-unavailable" { ++ w.WriteHeader(503) ++ return ++ } ++ claims["person_reference"] = "synthetic-domain-reference" ++ key := providerKey ++ switch mode { ++ case "userinfo-wrong-issuer": ++ claims["iss"] = "urn:other" ++ case "userinfo-wrong-audience": ++ claims["aud"] = "other-client" ++ case "userinfo-wrong-sub": ++ claims["sub"] = "other-citizen" ++ case "userinfo-missing-sub": ++ delete(claims, "sub") ++ case "userinfo-missing-essential": ++ delete(claims, "person_reference") ++ case "userinfo-wrong-key": ++ key = wrongKey ++ case "userinfo-unsigned": ++ _ = json.NewEncoder(w).Encode(claims) ++ return ++ } ++ _, _ = w.Write([]byte(protocolSign(t, key, claims, "PS256", "provider-key"))) ++ default: ++ t.Error("unexpected endpoint or followed redirect") ++ w.WriteHeader(400) ++ } ++ })) ++ defer server.Close() ++ issuer = server.URL ++ properties := map[string]string{idp.PropClientID: "outbound-client", idp.PropTokenEndpointAuthMethod: "private_key_jwt", idp.PropIDTokenSigningAlg: "PS256", idp.PropUserInfoSigningAlg: "PS256", idp.PropRequiredClaims: "person_reference", idp.PropIssuer: issuer, idp.PropRedirectURI: "http://127.0.0.1:9999/callback", idp.PropScopes: "openid", idp.PropAuthorizationEndpoint: issuer + "/authorize", idp.PropTokenEndpoint: issuer + "/token", idp.PropUserInfoEndpoint: issuer + "/userinfo", idp.PropJwksEndpoint: issuer + "/jwks"} ++ dto := &providers.IDPDTO{ID: "fixture-connection", Type: providers.IDPTypeOIDC} ++ for name, value := range properties { ++ prop, e := cmodels.NewProperty(name, value, false) ++ require.NoError(t, e) ++ dto.Properties = append(dto.Properties, *prop) ++ } ++ idpSvc := idpmock.NewIDPServiceInterfaceMock(t) ++ idpSvc.On("GetIdentityProvider", mock.Anything, "fixture-connection").Return(dto, nil) ++ svc := newOIDCAuthnService(oauth.Initialize(idpSvc, nil), jwtSvc) ++ for _, scenario := range []string{"ok", "id-wrong-issuer", "id-wrong-audience", "id-multi-audience", "id-wrong-azp", "id-no-sub", "id-expired", "id-future", "id-wrong-nonce", "id-wrong-key", "id-wrong-alg", "id-unknown-kid", "duplicate-kid", "jwks-wrong-alg", "jwks-redirect", "token-redirect", "userinfo-wrong-issuer", "userinfo-wrong-audience", "userinfo-wrong-sub", "userinfo-missing-sub", "userinfo-missing-essential", "userinfo-wrong-key", "userinfo-unsigned", "userinfo-unavailable", "userinfo-redirect", "bad-verifier", "missing-verifier", "ok-after-refusals"} { ++ t.Run(scenario, func(t *testing.T) { ++ mode = scenario ++ authorize, metadata, svcErr := svc.BuildAuthorizeURL(context.Background(), "fixture-connection") ++ require.Nil(t, svcErr) ++ resp, e := http.Get(authorize) ++ require.NoError(t, e) ++ require.Equal(t, 200, resp.StatusCode) ++ resp.Body.Close() ++ verifier := metadata["code_verifier"] ++ if mode == "bad-verifier" { ++ verifier = strings.Repeat("a", 43) ++ } ++ if mode == "missing-verifier" { ++ verifier = "" ++ } ++ authz := authncm.AuthorizationData{Code: code, Nonce: metadata["nonce"], CodeVerifier: verifier} ++ result, svcErr := svc.Authenticate(context.Background(), "fixture-connection", authz) ++ if strings.HasPrefix(mode, "ok") { ++ require.Nil(t, svcErr) ++ require.NotNil(t, result) ++ require.Equal(t, map[string]interface{}{"sub": "pairwise-citizen", "registry_federation_issuer": issuer}, result.Token) ++ require.Equal(t, issuer, result.AuthenticatedClaims["registry_federation_issuer"]) ++ require.Equal(t, "synthetic-domain-reference", result.AuthenticatedClaims["person_reference"]) ++ replay, err := svc.Authenticate(context.Background(), "fixture-connection", authz) ++ require.Nil(t, replay) ++ require.NotNil(t, err) ++ } else { ++ require.Nil(t, result) ++ require.NotNil(t, svcErr) ++ } ++ }) ++ } ++ require.Greater(t, tokenCalls, 20) ++ require.Greater(t, len(seenJTIs), 20) ++ c, e := svc.GetOAuthClientConfig(context.Background(), "fixture-connection") ++ require.Nil(t, e) ++ c.OAuthEndpoints.JwksEndpoint = "https://other.example/jwks" ++ require.Error(t, c.ValidateFederation()) ++ _, err = url.Parse(issuer) ++ require.NoError(t, err) ++} +diff --git a/backend/internal/authn/oidc/service.go b/backend/internal/authn/oidc/service.go +--- a/backend/internal/authn/oidc/service.go ++++ b/backend/internal/authn/oidc/service.go +@@ -76,6 +76,17 @@ + } + metadata[oauth2const.RequestParamNonce] = nonce + ++ config, configErr := s.GetOAuthClientConfig(ctx, idpID) ++ if configErr != nil { ++ return "", nil, configErr ++ } ++ if config.TokenEndpointAuthMethod == "private_key_jwt" { ++ var err error ++ authorizeURL, err = bindPKCE(authorizeURL, metadata, config) ++ if err != nil { ++ return "", nil, &tidcommon.InternalServerError ++ } ++ } + return authorizeURL, metadata, nil + } + +@@ -83,6 +94,22 @@ + // and validates the token response if validateResponse is true. + func (s *oidcAuthnService) ExchangeCodeForToken(ctx context.Context, idpID, code string, validateResponse bool) ( + *authnoauth.TokenResponse, *tidcommon.ServiceError) { ++ config, configErr := s.GetOAuthClientConfig(ctx, idpID) ++ if configErr != nil { ++ return nil, configErr ++ } ++ if config.TokenEndpointAuthMethod == "private_key_jwt" { ++ verifier, _ := ctx.Value(federationVerifierKey{}).(string) ++ if len(verifier) != 43 { ++ return nil, &ErrorInvalidIDToken ++ } ++ assertion, _, err := s.jwtService.GenerateJWT(ctx, config.ClientID, config.ClientID, 60, ++ map[string]interface{}{"aud": config.OAuthEndpoints.TokenEndpoint}, "JWT", "RS256") ++ if err != nil { ++ return nil, err ++ } ++ ctx = authnoauth.WithFederationExchange(ctx, verifier, assertion) ++ } + tokenResp, svcErr := s.internal.ExchangeCodeForToken(ctx, idpID, code, false) + if svcErr != nil { + return nil, svcErr +@@ -147,6 +174,10 @@ + return svcErr + } + ++ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { ++ _, err := s.verifyFederationJWT(ctx, idToken, oAuthClientConfig.IDTokenSigningAlg, oAuthClientConfig) ++ return err ++ } + // Validate ID token signature using JWKS endpoint if available + if oAuthClientConfig.OAuthEndpoints.JwksEndpoint != "" { + err := s.jwtService.VerifyJWTWithJWKS(ctx, idToken, oAuthClientConfig.OAuthEndpoints.JwksEndpoint, "", "") +@@ -189,6 +220,17 @@ + // FetchUserInfo retrieves user information from the external identity provider. + func (s *oidcAuthnService) FetchUserInfo(ctx context.Context, idpID, accessToken string) ( + map[string]interface{}, *tidcommon.ServiceError) { ++ config, err := s.GetOAuthClientConfig(ctx, idpID) ++ if err != nil { ++ return nil, err ++ } ++ if config.TokenEndpointAuthMethod == "private_key_jwt" { ++ body, err := readFederationResponse(ctx, config.OAuthEndpoints.UserInfoEndpoint, accessToken, "application/jwt") ++ if err != nil { ++ return nil, &ErrorInvalidIDToken ++ } ++ return s.verifyFederationJWT(ctx, string(body), config.UserInfoSigningAlg, config) ++ } + return s.internal.FetchUserInfo(ctx, idpID, accessToken) + } + +@@ -200,6 +242,7 @@ + logger := s.logger.With(log.String("idpId", idpID)) + logger.Debug(ctx, "Performing federated OIDC authentication") + ++ ctx = context.WithValue(ctx, federationVerifierKey{}, authzData.CodeVerifier) + tokenResp, svcErr := s.ExchangeCodeForToken(ctx, idpID, authzData.Code, true) + if svcErr != nil { + return nil, svcErr +@@ -231,7 +274,30 @@ + // Fetch user info if a UserInfo endpoint and additional scopes are configured, so callers get the + // full attribute set. The identity itself comes from the ID token, so a failed fetch is not fatal. + oauthConfig, svcErr := s.GetOAuthClientConfig(ctx, idpID) +- if svcErr == nil && oauthConfig.OAuthEndpoints.UserInfoEndpoint != "" && len(oauthConfig.Scopes) > 1 { ++ if svcErr != nil { ++ return nil, svcErr ++ } ++ if oauthConfig.TokenEndpointAuthMethod == "private_key_jwt" { ++ userInfo, err := s.FetchUserInfo(ctx, idpID, tokenResp.AccessToken) ++ if err != nil { ++ return nil, err ++ } ++ if userInfo["sub"] != sub { ++ return nil, &ErrorInvalidIDToken ++ } ++ for _, claim := range oauthConfig.RequiredClaims { ++ value, ok := userInfo[claim].(string) ++ if !ok || strings.TrimSpace(value) == "" || len(value) > 4096 { ++ return nil, &ErrorInvalidIDToken ++ } ++ claims[claim] = value ++ } ++ for k, v := range userInfo { ++ if _, exists := claims[k]; !exists { ++ claims[k] = v ++ } ++ } ++ } else if oauthConfig.OAuthEndpoints.UserInfoEndpoint != "" && len(oauthConfig.Scopes) > 1 { + userInfo, infoErr := s.FetchUserInfo(ctx, idpID, tokenResp.AccessToken) + if infoErr == nil { + if userInfoSub, ok := userInfo["sub"].(string); !ok || userInfoSub == sub { +@@ -246,7 +312,21 @@ + } + } + +- return s.internal.BuildFederatedAuthResult(ctx, idpID, sub, claims) ++ result, resultErr := s.internal.BuildFederatedAuthResult(ctx, idpID, sub, claims) ++ if resultErr != nil { ++ return nil, resultErr ++ } ++ if oauthConfig.TokenEndpointAuthMethod == "private_key_jwt" { ++ // A pairwise subject is scoped to its verified issuer. Disable account ++ // linking by mutable claims on this governed citizen federation path. ++ result.Token = map[string]interface{}{"sub": sub, "registry_federation_issuer": oauthConfig.Issuer} ++ if result.AuthenticatedClaims == nil { ++ result.AuthenticatedClaims = make(map[string]interface{}) ++ } ++ result.AuthenticatedClaims["sub"] = sub ++ result.AuthenticatedClaims["registry_federation_issuer"] = oauthConfig.Issuer ++ } ++ return result, nil + } + + // BuildFederatedAuthResult delegates to the underlying OAuth service, which applies attribute mapping +diff --git a/backend/internal/authn/oidc/service_test.go b/backend/internal/authn/oidc/service_test.go +--- a/backend/internal/authn/oidc/service_test.go ++++ b/backend/internal/authn/oidc/service_test.go +@@ -92,6 +92,8 @@ + } + + func (suite *OIDCAuthnServiceTestSuite) TestBuildAuthorizeURLSuccess() { ++ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) ++ + expectedURL := "https://example.com/authorize?client_id=test" + suite.mockOAuthService.On("BuildAuthorizeURL", mock.Anything, testOIDCIDPID). + Return(expectedURL, map[string]string{oauth2const.RequestParamState: "test-state"}, nil) +@@ -104,6 +106,7 @@ + } + + func (suite *OIDCAuthnServiceTestSuite) TestBuildAuthorizeURLError() { ++ + svcErr := &tidcommon.ServiceError{ + Code: "ERROR", + ErrorDescription: tidcommon.I18nMessage{ +@@ -151,6 +154,7 @@ + name: "WithoutValidation", + validateResponse: false, + setupMocks: func() { ++ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) + code := "auth_code" + tokenResp := &oauth.TokenResponse{ + AccessToken: "access_token", +@@ -336,6 +340,8 @@ + } + + func (suite *OIDCAuthnServiceTestSuite) TestFetchUserInfoSuccess() { ++ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) ++ + accessToken := "access_token" + userInfo := map[string]interface{}{ + "sub": "user123", +@@ -350,6 +356,8 @@ + } + + func (suite *OIDCAuthnServiceTestSuite) TestExchangeCodeForTokenInternalError() { ++ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) ++ + suite.mockOAuthService.On("ExchangeCodeForToken", mock.Anything, testOIDCIDPID, "auth_code", false). + Return(nil, &tidcommon.ServiceError{Code: "INT-ERR"}) + +@@ -599,6 +607,7 @@ + } + + func (suite *OIDCAuthnServiceTestSuite) TestAuthenticateExchangeCodeError() { ++ + suite.mockOAuthService = oauthmock.NewOAuthAuthnServiceInterfaceMock(suite.T()) + suite.mockJWTService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) + +@@ -607,6 +616,7 @@ + suite.True(ok) + suite.service = *cast + ++ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) + suite.mockOAuthService.On("ExchangeCodeForToken", mock.Anything, testOIDCIDPID, "bad_code", false). + Return(nil, &tidcommon.ServiceError{Code: "TOKEN-ERR"}) + +diff --git a/backend/internal/connection/connection_declarative_model.go b/backend/internal/connection/connection_declarative_model.go +--- a/backend/internal/connection/connection_declarative_model.go ++++ b/backend/internal/connection/connection_declarative_model.go +@@ -19,18 +19,22 @@ + Description string `yaml:"description,omitempty" json:"description,omitempty"` + + // IdP-backed vendor fields (google, github, oidc, oauth). +- ClientID string `yaml:"clientId,omitempty" json:"clientId,omitempty"` +- ClientSecret string `yaml:"clientSecret,omitempty" json:"clientSecret,omitempty"` +- RedirectURI string `yaml:"redirectUri,omitempty" json:"redirectUri,omitempty"` +- Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` +- Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` +- AuthorizationEndpoint string `yaml:"authorizationEndpoint,omitempty" json:"authorizationEndpoint,omitempty"` +- TokenEndpoint string `yaml:"tokenEndpoint,omitempty" json:"tokenEndpoint,omitempty"` +- UserInfoEndpoint string `yaml:"userInfoEndpoint,omitempty" json:"userInfoEndpoint,omitempty"` +- JwksEndpoint string `yaml:"jwksEndpoint,omitempty" json:"jwksEndpoint,omitempty"` +- Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` +- TokenExchangeEnabled *bool `yaml:"tokenExchangeEnabled,omitempty" json:"tokenExchangeEnabled,omitempty"` +- TrustedTokenAudience string `yaml:"trustedTokenAudience,omitempty" json:"trustedTokenAudience,omitempty"` ++ TokenEndpointAuthMethod string `yaml:"tokenEndpointAuthMethod,omitempty" json:"tokenEndpointAuthMethod,omitempty"` ++ IDTokenSigningAlg string `yaml:"idTokenSigningAlg,omitempty" json:"idTokenSigningAlg,omitempty"` ++ UserInfoSigningAlg string `yaml:"userInfoSigningAlg,omitempty" json:"userInfoSigningAlg,omitempty"` ++ RequiredClaims []string `yaml:"requiredClaims,omitempty" json:"requiredClaims,omitempty"` ++ ClientID string `yaml:"clientId,omitempty" json:"clientId,omitempty"` ++ ClientSecret string `yaml:"clientSecret,omitempty" json:"clientSecret,omitempty"` ++ RedirectURI string `yaml:"redirectUri,omitempty" json:"redirectUri,omitempty"` ++ Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` ++ Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` ++ AuthorizationEndpoint string `yaml:"authorizationEndpoint,omitempty" json:"authorizationEndpoint,omitempty"` ++ TokenEndpoint string `yaml:"tokenEndpoint,omitempty" json:"tokenEndpoint,omitempty"` ++ UserInfoEndpoint string `yaml:"userInfoEndpoint,omitempty" json:"userInfoEndpoint,omitempty"` ++ JwksEndpoint string `yaml:"jwksEndpoint,omitempty" json:"jwksEndpoint,omitempty"` ++ Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` ++ TokenExchangeEnabled *bool `yaml:"tokenExchangeEnabled,omitempty" json:"tokenExchangeEnabled,omitempty"` ++ TrustedTokenAudience string `yaml:"trustedTokenAudience,omitempty" json:"trustedTokenAudience,omitempty"` + + //nolint:lll // long struct tag: both yaml and json keys needed for declarative load/export and import + AttributeConfiguration *providers.AttributeConfiguration `yaml:"attributeConfiguration,omitempty" json:"attributeConfiguration,omitempty"` +diff --git a/backend/internal/connection/declarative_resource.go b/backend/internal/connection/declarative_resource.go +--- a/backend/internal/connection/declarative_resource.go ++++ b/backend/internal/connection/declarative_resource.go +@@ -218,11 +218,16 @@ + } + + model := connectionExportModel{ +- ID: dto.ID, +- Type: vendor, +- Name: dto.Name, +- Description: dto.Description, +- ClientID: values[idp.PropClientID], ++ ID: dto.ID, ++ Type: vendor, ++ Name: dto.Name, ++ Description: dto.Description, ++ ClientID: values[idp.PropClientID], ++ TokenEndpointAuthMethod: values[idp.PropTokenEndpointAuthMethod], ++ IDTokenSigningAlg: values[idp.PropIDTokenSigningAlg], ++ UserInfoSigningAlg: values[idp.PropUserInfoSigningAlg], ++ RequiredClaims: splitScopes(values[idp.PropRequiredClaims]), ++ + ClientSecret: values[idp.PropClientSecret], + RedirectURI: values[idp.PropRedirectURI], + Scopes: splitScopes(values[idp.PropScopes]), +@@ -309,6 +314,11 @@ + return dto, nil, nil + case "oidc": + dto, err := oidcToIDPDTO(oidcConnectionRequest{ ++ TokenEndpointAuthMethod: model.TokenEndpointAuthMethod, ++ IDTokenSigningAlg: model.IDTokenSigningAlg, ++ UserInfoSigningAlg: model.UserInfoSigningAlg, ++ RequiredClaims: model.RequiredClaims, ++ + Name: model.Name, Description: model.Description, ClientID: model.ClientID, + ClientSecret: model.ClientSecret, RedirectURI: model.RedirectURI, + AuthorizationEndpoint: model.AuthorizationEndpoint, TokenEndpoint: model.TokenEndpoint, +diff --git a/backend/internal/connection/oidc.go b/backend/internal/connection/oidc.go +--- a/backend/internal/connection/oidc.go ++++ b/backend/internal/connection/oidc.go +@@ -13,44 +13,52 @@ + + // oidcConnectionRequest is the create/update payload for a generic OpenID Connect connection. + type oidcConnectionRequest struct { +- Name string `json:"name"` +- Description string `json:"description,omitempty"` +- ClientID string `json:"clientId"` +- ClientSecret string `json:"clientSecret"` +- RedirectURI string `json:"redirectUri"` +- AuthorizationEndpoint string `json:"authorizationEndpoint"` +- TokenEndpoint string `json:"tokenEndpoint"` +- UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` +- JwksEndpoint string `json:"jwksEndpoint,omitempty"` +- Issuer string `json:"issuer,omitempty"` +- Scopes []string `json:"scopes,omitempty"` +- Prompt string `json:"prompt,omitempty"` +- TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` +- TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` +- IDJagEnabled *bool `json:"idJagEnabled,omitempty"` ++ Name string `json:"name"` ++ Description string `json:"description,omitempty"` ++ TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` ++ IDTokenSigningAlg string `json:"idTokenSigningAlg,omitempty"` ++ UserInfoSigningAlg string `json:"userInfoSigningAlg,omitempty"` ++ RequiredClaims []string `json:"requiredClaims,omitempty"` ++ ClientID string `json:"clientId"` ++ ClientSecret string `json:"clientSecret"` ++ RedirectURI string `json:"redirectUri"` ++ AuthorizationEndpoint string `json:"authorizationEndpoint"` ++ TokenEndpoint string `json:"tokenEndpoint"` ++ UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` ++ JwksEndpoint string `json:"jwksEndpoint,omitempty"` ++ Issuer string `json:"issuer,omitempty"` ++ Scopes []string `json:"scopes,omitempty"` ++ Prompt string `json:"prompt,omitempty"` ++ TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` ++ TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` ++ IDJagEnabled *bool `json:"idJagEnabled,omitempty"` + + AttributeConfiguration *providers.AttributeConfiguration `json:"attributeConfiguration,omitempty"` + } + + // oidcConnectionResponse is the detail payload for an OIDC connection (secret masked). + type oidcConnectionResponse struct { +- ID string `json:"id"` +- Name string `json:"name"` +- Description string `json:"description,omitempty"` +- Type string `json:"type"` +- ClientID string `json:"clientId,omitempty"` +- ClientSecret string `json:"clientSecret,omitempty"` +- RedirectURI string `json:"redirectUri,omitempty"` +- AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` +- TokenEndpoint string `json:"tokenEndpoint,omitempty"` +- UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` +- JwksEndpoint string `json:"jwksEndpoint,omitempty"` +- Issuer string `json:"issuer,omitempty"` +- Scopes []string `json:"scopes,omitempty"` +- Prompt string `json:"prompt,omitempty"` +- TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` +- TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` +- IDJagEnabled *bool `json:"idJagEnabled,omitempty"` ++ ID string `json:"id"` ++ Name string `json:"name"` ++ Description string `json:"description,omitempty"` ++ Type string `json:"type"` ++ TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` ++ IDTokenSigningAlg string `json:"idTokenSigningAlg,omitempty"` ++ UserInfoSigningAlg string `json:"userInfoSigningAlg,omitempty"` ++ RequiredClaims []string `json:"requiredClaims,omitempty"` ++ ClientID string `json:"clientId,omitempty"` ++ ClientSecret string `json:"clientSecret,omitempty"` ++ RedirectURI string `json:"redirectUri,omitempty"` ++ AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` ++ TokenEndpoint string `json:"tokenEndpoint,omitempty"` ++ UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` ++ JwksEndpoint string `json:"jwksEndpoint,omitempty"` ++ Issuer string `json:"issuer,omitempty"` ++ Scopes []string `json:"scopes,omitempty"` ++ Prompt string `json:"prompt,omitempty"` ++ TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` ++ TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` ++ IDJagEnabled *bool `json:"idJagEnabled,omitempty"` + + AttributeConfiguration *providers.AttributeConfiguration `json:"attributeConfiguration,omitempty"` + } +@@ -64,6 +72,11 @@ + isSecret bool + }{ + {idp.PropClientID, req.ClientID, false}, ++ {idp.PropTokenEndpointAuthMethod, req.TokenEndpointAuthMethod, false}, ++ {idp.PropIDTokenSigningAlg, req.IDTokenSigningAlg, false}, ++ {idp.PropUserInfoSigningAlg, req.UserInfoSigningAlg, false}, ++ {idp.PropRequiredClaims, joinScopes(req.RequiredClaims), false}, ++ + {idp.PropClientSecret, req.ClientSecret, true}, + {idp.PropRedirectURI, req.RedirectURI, false}, + {idp.PropAuthorizationEndpoint, req.AuthorizationEndpoint, false}, +@@ -115,11 +128,16 @@ + return oidcConnectionResponse{}, err + } + resp := oidcConnectionResponse{ +- ID: dto.ID, +- Name: dto.Name, +- Description: dto.Description, +- Type: connectionTypeName(dto.Type), +- ClientID: values[idp.PropClientID], ++ ID: dto.ID, ++ Name: dto.Name, ++ Description: dto.Description, ++ Type: connectionTypeName(dto.Type), ++ ClientID: values[idp.PropClientID], ++ TokenEndpointAuthMethod: values[idp.PropTokenEndpointAuthMethod], ++ IDTokenSigningAlg: values[idp.PropIDTokenSigningAlg], ++ UserInfoSigningAlg: values[idp.PropUserInfoSigningAlg], ++ RequiredClaims: splitScopes(values[idp.PropRequiredClaims]), ++ + ClientSecret: values[idp.PropClientSecret], + RedirectURI: values[idp.PropRedirectURI], + AuthorizationEndpoint: values[idp.PropAuthorizationEndpoint], +diff --git a/backend/internal/flow/executor/auth_assert_executor.go b/backend/internal/flow/executor/auth_assert_executor.go +--- a/backend/internal/flow/executor/auth_assert_executor.go ++++ b/backend/internal/flow/executor/auth_assert_executor.go +@@ -5,6 +5,7 @@ + + import ( + "context" ++ "encoding/json" + "errors" + "slices" + "sort" +@@ -275,6 +276,18 @@ + return "", attrErr + } + ++ // User attributes can never supply the server-only consent binding. ++ delete(resolvedAttributes, providers.DelegationConsentCacheKey) ++ delete(resolvedAttributes, providers.DelegationConsentPolicyKey) ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ var binding providers.DelegationConsentBinding ++ if json.Unmarshal([]byte(ctx.RuntimeData[providers.DelegationConsentCacheKey]), &binding) != nil || binding.AuthorizationID == "" || binding.UserID != entityRef.EntityID || binding.AppID != ctx.Application.ID { ++ return "", errors.New("fresh citizen consent binding is missing") ++ } ++ binding.Subject = tokenSub ++ encoded, _ := json.Marshal(binding) ++ resolvedAttributes[providers.DelegationConsentCacheKey] = string(encoded) ++ } + if ttlSecondsStr, exists := ctx.RuntimeData[common.RuntimeKeyUserAttributesCacheTTLSeconds]; exists { + // We are not in an App Native flow, so we need to cache the user attributes + if len(resolvedAttributes) > 0 { +diff --git a/backend/internal/flow/executor/consent_executor.go b/backend/internal/flow/executor/consent_executor.go +--- a/backend/internal/flow/executor/consent_executor.go ++++ b/backend/internal/flow/executor/consent_executor.go +@@ -4,9 +4,12 @@ + package executor + + import ( ++ "context" + "encoding/json" + "errors" ++ "fmt" + "html" ++ "reflect" + "slices" + "strconv" + "strings" +@@ -106,6 +109,11 @@ + return execResp, errors.New("Failed to get entity reference from AuthUser") + } + ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil && (entityRef == nil || entityRef.EntityCategory != string(providers.EntityCategoryUser)) { ++ execResp.Status = providers.ExecFailure ++ execResp.Error = &ErrUserNotAuthenticated ++ return execResp, nil ++ } + availableAttrs, svcErr := e.authnProvider.GetUserAvailableAttributes(ctx.Context, execResp.AuthUser) + execResp.AuthUser = authUser + if svcErr != nil { +@@ -141,6 +149,28 @@ + availableAttributes := e.buildAugmentedAvailableAttributes(availableAttrResp, entityRef) + appName := ctx.Application.Name + forceReprompt := ctx.RuntimeData[common.RuntimeKeyForceConsentReprompt] == "true" ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ if consent.Delegation.Validate() != nil || consent.ValidityPeriod <= 0 || consent.ValidityPeriod > 300 || ctx.RuntimeData[common.RuntimeKeyResourceServerIdentifier] != consent.Delegation.Resource { ++ return nil, errors.New("citizen delegation policy is invalid") ++ } ++ requested := strings.Fields(ctx.RuntimeData[common.RuntimeKeyRequestedPermissions]) ++ if len(requested) == 0 { ++ return nil, errors.New("citizen delegation requires requested permissions") ++ } ++ for _, scope := range requested { ++ if _, ok := consent.Delegation.ScopeFields[scope]; !ok { ++ return nil, errors.New("permission has no consent field description") ++ } ++ } ++ if existing, ok := ctx.RuntimeData["authorized_permissions"]; ok { ++ requested = strings.Fields(intersectPermissionSpaceList(strings.Join(requested, " "), existing)) ++ } ++ authorizedPermissions = requested ++ forceReprompt = true ++ execResp.AdditionalData["citizenConsentWithdrawal"] = "true" ++ policy, _ := json.Marshal(consent.Delegation) ++ execResp.RuntimeData[providers.DelegationConsentPolicyKey] = string(policy) ++ } + + // Resolve consent to determine if any required consents are missing and need to be prompted + promptData, svcErr := e.consentEnforcer.ResolveConsent( +@@ -166,6 +196,20 @@ + return execResp, nil + } + ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ for i := range promptData.Purposes { ++ prompt := &promptData.Purposes[i] ++ fields := []string{} ++ for _, scope := range authorizedPermissions { ++ for _, field := range consent.Delegation.ScopeFields[scope] { ++ if !slices.Contains(fields, field) { ++ fields = append(fields, field) ++ } ++ } ++ } ++ prompt.Description = fmt.Sprintf("%s may use %s for %s. Fields: %s. Access lasts at most %d seconds. A later request requires your consent again.", appName, consent.Delegation.Resource, consent.Delegation.Purpose, strings.Join(fields, ", "), consent.ValidityPeriod) ++ } ++ } + // Consent is needed — forward prompt data to the prompt node via ForwardedData + promptJSON, err := json.Marshal(promptData.Purposes) + if err != nil { +@@ -227,6 +271,29 @@ + } + + failOnDeny, _ := ctx.NodeProperties[propertyKeyConsentFailOnDeny].(bool) ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ failOnDeny = true ++ var shown providers.DelegationConsentConfig ++ if json.Unmarshal([]byte(ctx.RuntimeData[providers.DelegationConsentPolicyKey]), &shown) != nil || !reflect.DeepEqual(shown, *consent.Delegation) { ++ return nil, errors.New("citizen consent policy changed during prompt") ++ } ++ } ++ ++ if decisions.Reason == providers.ConsentDecisionReason("withdrawal") { ++ consent := ctx.Application.LoginConsent ++ withdrawer, ok := e.consentEnforcer.(interface { ++ WithdrawAuthenticatedConsent(context.Context, string, string) *tidcommon.ServiceError ++ }) ++ if consent == nil || consent.Delegation == nil || !ok { ++ return nil, errors.New("citizen withdrawal is not configured") ++ } ++ if err := withdrawer.WithdrawAuthenticatedConsent(ctx.Context, appID, userID); err != nil { ++ return nil, errors.New("citizen withdrawal could not be recorded") ++ } ++ execResp.Status = providers.ExecFailure ++ execResp.Error = &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "CITIZEN-CONSENT-WITHDRAWN", Error: tidcommon.I18nMessage{DefaultValue: "Access withdrawn"}, ErrorDescription: tidcommon.I18nMessage{DefaultValue: "This agent's consent has been withdrawn. No new access was issued."}} ++ return execResp, nil ++ } + + // A timed out prompt is not a user decision, so nothing is recorded and nothing is consented. + // The expiry check below is skipped because such a submission is expected to arrive late. +@@ -255,6 +322,14 @@ + return execResp, nil + } + } ++ } ++ ++ // Declining this request does not withdraw an earlier approval. Withdrawal ++ // is an explicit, separately authenticated action above. ++ if c := ctx.Application.LoginConsent; c != nil && c.Delegation != nil && !decisions.Approved { ++ execResp.Status = providers.ExecFailure ++ execResp.Error = &ErrConsentDenied ++ return execResp, nil + } + + // Determine validity period from the application config +@@ -309,10 +384,30 @@ + // understand the full consent data structure. Both keys are always set (even if empty) so + // auth assert knows that the consent step ran and can apply the appropriate precedence chain. + consentedAttrs := collectApprovedElementsByPurposeName(consentRecord, buildAttributePurposeName(appID)) ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ essential, optional := e.getRequiredAttributes(ctx) ++ consentedAttrs = strings.Fields(intersectPermissionSpaceList(strings.Join(consentedAttrs, " "), strings.Join(append(essential, optional...), " "))) ++ } + execResp.RuntimeData[common.RuntimeKeyConsentedAttributes] = strings.Join(consentedAttrs, " ") + consentedPerms := collectApprovedElementsByPurposeName(consentRecord, buildPermissionsPurposeName(appID)) + execResp.RuntimeData[common.RuntimeKeyConsentedPermissions] = strings.Join(consentedPerms, " ") + ++ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { ++ binding := providers.DelegationConsentBinding{ConsentID: consentRecord.ID, AppID: appID, UserID: userID, ValidityPeriod: consent.ValidityPeriod, Deadline: consentRecord.ValidityTime, Policy: *consent.Delegation, Scopes: consentedPerms, Attributes: consentedAttrs} ++ for _, authorization := range consentRecord.Authorizations { ++ if authorization.UserID == userID && authorization.Status == providers.AuthorizationStatusApproved { ++ binding.AuthorizationID = authorization.ID ++ } ++ } ++ if binding.AuthorizationID == "" || binding.Deadline <= time.Now().Unix() || binding.Deadline > time.Now().Unix()+300 { ++ return nil, errors.New("consent authorization binding is missing or expired") ++ } ++ // Merged native records may include earlier scopes. Retain only this attempt's prompt. ++ binding.Scopes = strings.Fields(intersectPermissionSpaceList(strings.Join(binding.Scopes, " "), ctx.RuntimeData[common.RuntimeKeyRequestedPermissions])) ++ execResp.RuntimeData[common.RuntimeKeyConsentedPermissions] = strings.Join(binding.Scopes, " ") ++ encoded, _ := json.Marshal(binding) ++ execResp.RuntimeData[providers.DelegationConsentCacheKey] = string(encoded) ++ } + logger.Debug(ctx.Context, "Consent recorded successfully", log.String("consentID", consentRecord.ID)) + execResp.Status = providers.ExecComplete + return execResp, nil +diff --git a/backend/internal/flow/executor/consent_executor_test.go b/backend/internal/flow/executor/consent_executor_test.go +--- a/backend/internal/flow/executor/consent_executor_test.go ++++ b/backend/internal/flow/executor/consent_executor_test.go +@@ -1926,3 +1926,53 @@ + + assert.Nil(suite.T(), result) + } ++ ++type withdrawalConsent struct { ++ providers.ConsentProvider ++ appID, userID string ++ calls int ++} ++ ++func (c *withdrawalConsent) WithdrawAuthenticatedConsent(_ context.Context, appID, userID string) *tidcommon.ServiceError { ++ c.appID = appID ++ c.userID = userID ++ c.calls++ ++ return nil ++} ++func (suite *ConsentExecutorTestSuite) TestCitizenWithdrawalAndDeclineAreDistinct() { ++ ctx := buildConsentNodeContext() ++ policy := &providers.DelegationConsentConfig{Purpose: "Read status", Resource: "urn:records", ScopeFields: map[string][]string{"read": {"status"}}} ++ ctx.Application.LoginConsent = &providers.LoginConsentConfig{ValidityPeriod: 300, Delegation: policy} ++ raw, _ := json.Marshal(policy) ++ ctx.RuntimeData[providers.DelegationConsentPolicyKey] = string(raw) ++ withdrawer := &withdrawalConsent{ConsentProvider: suite.mockConsentEnforcer} ++ suite.executor.consentEnforcer = withdrawer ++ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false}` ++ response, err := suite.executor.handleConsentDecisions(ctx, &providers.ExecutorResponse{}, "ou", "server-agent", "verified-citizen") ++ suite.NoError(err) ++ suite.Equal(providers.ExecFailure, response.Status) ++ suite.Zero(withdrawer.calls) ++ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false,"reason":"withdrawal"}` ++ response, err = suite.executor.handleConsentDecisions(ctx, &providers.ExecutorResponse{}, "ou", "server-agent", "verified-citizen") ++ suite.NoError(err) ++ suite.Equal(providers.ExecFailure, response.Status) ++ suite.Equal("CITIZEN-CONSENT-WITHDRAWN", response.Error.Code) ++ suite.Equal(1, withdrawer.calls) ++ suite.Equal("server-agent", withdrawer.appID) ++ suite.Equal("verified-citizen", withdrawer.userID) ++ suite.mockConsentEnforcer.AssertNotCalled(suite.T(), "RecordConsent", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) ++} ++ ++func (suite *ConsentExecutorTestSuite) TestCitizenWithdrawalRequiresVerifiedHuman() { ++ ctx := buildConsentNodeContext() ++ ctx.Application.LoginConsent = &providers.LoginConsentConfig{ValidityPeriod: 300, Delegation: &providers.DelegationConsentConfig{Purpose: "Read status", Resource: "urn:records", ScopeFields: map[string][]string{"read": {"status"}}}} ++ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false,"reason":"withdrawal"}` ++ suite.executor.Executor.(*coremock.ExecutorInterfaceMock).On("ValidatePrerequisites", ctx, mock.Anything, mock.Anything).Return(true) ++ ref := buildConsentEntityRef() ++ ref.EntityCategory = string(providers.EntityCategoryAgent) ++ suite.mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything).Return(buildConsentAuthUser(), ref, (*tidcommon.ServiceError)(nil)) ++ response, err := suite.executor.Execute(ctx) ++ suite.NoError(err) ++ suite.Equal(providers.ExecFailure, response.Status) ++ suite.Equal(ErrUserNotAuthenticated.Code, response.Error.Code) ++} +diff --git a/backend/internal/flow/executor/oidc_auth_executor.go b/backend/internal/flow/executor/oidc_auth_executor.go +--- a/backend/internal/flow/executor/oidc_auth_executor.go ++++ b/backend/internal/flow/executor/oidc_auth_executor.go +@@ -131,6 +131,9 @@ + return nil, errors.New("OIDC nonce is missing in the authorization flow") + } + execResp.RuntimeData[common.RuntimeKeyOIDCNonce] = nonce ++ if verifier, ok := metadata["code_verifier"]; ok { ++ execResp.RuntimeData["oidc_code_verifier"] = verifier ++ } + + return metadata, nil + } +@@ -150,6 +153,11 @@ + // Validate the OAuth state parameter to prevent CSRF attacks. + // State is validated only when the client sends it back. Clients that handle CSRF + // protection client-side (e.g., via sessionStorage) may omit it. ++ if ctx.RuntimeData["oidc_code_verifier"] != "" && (ctx.RuntimeData[common.RuntimeKeyOAuthState] == "" || ctx.UserInputs[userInputState] != ctx.RuntimeData[common.RuntimeKeyOAuthState]) { ++ execResp.Status = providers.ExecFailure ++ execResp.Error = &ErrInvalidOAuthState ++ return nil ++ } + if returnedState, ok := ctx.UserInputs[userInputState]; ok && returnedState != "" { + expectedState := ctx.RuntimeData[common.RuntimeKeyOAuthState] + if returnedState != expectedState { +@@ -186,8 +194,9 @@ + IDPID: idpID, + IDPType: o.idpType, + AuthorizationData: authncm.AuthorizationData{ +- Code: code, +- Nonce: ctx.RuntimeData[common.RuntimeKeyOIDCNonce], ++ Code: code, ++ Nonce: ctx.RuntimeData[common.RuntimeKeyOIDCNonce], ++ CodeVerifier: ctx.RuntimeData["oidc_code_verifier"], + }, + }, + } +diff --git a/backend/internal/flow/executor/oidc_auth_executor_test.go b/backend/internal/flow/executor/oidc_auth_executor_test.go +--- a/backend/internal/flow/executor/oidc_auth_executor_test.go ++++ b/backend/internal/flow/executor/oidc_auth_executor_test.go +@@ -1146,3 +1146,14 @@ + assert.Contains(suite.T(), err.Error(), "OIDC authentication failed") + suite.mockAuthnProvider.AssertExpectations(suite.T()) + } ++ ++func (suite *OIDCAuthExecutorTestSuite) TestPrivateFederationRequiresReturnedState() { ++ for _, returned := range []string{"", "different-state"} { ++ ctx := &providers.NodeContext{ExecutionID: "fixture", UserInputs: map[string]string{"code": "synthetic-code", "state": returned}, RuntimeData: map[string]string{"oidc_code_verifier": "server-held", common.RuntimeKeyOAuthState: "expected-state"}} ++ resp := &providers.ExecutorResponse{} ++ err := suite.executor.ProcessAuthFlowResponse(ctx, resp) ++ suite.NoError(err) ++ suite.Equal(providers.ExecFailure, resp.Status) ++ suite.Equal(ErrInvalidOAuthState.Code, resp.Error.Code) ++ } ++} +diff --git a/backend/internal/idp/constants.go b/backend/internal/idp/constants.go +--- a/backend/internal/idp/constants.go ++++ b/backend/internal/idp/constants.go +@@ -7,7 +7,12 @@ + + // IDP property names. + const ( +- PropClientID = "client_id" ++ PropClientID = "client_id" ++ PropTokenEndpointAuthMethod = "token_endpoint_auth_method" ++ PropIDTokenSigningAlg = "id_token_signing_alg" ++ PropUserInfoSigningAlg = "userinfo_signing_alg" ++ PropRequiredClaims = "required_claims" ++ + PropClientSecret = "client_secret" + PropRedirectURI = "redirect_uri" + PropScopes = "scopes" +@@ -102,6 +107,10 @@ + PropTokenExchangeEnabled, + PropTrustedTokenAudience, + PropIDJagEnabled, ++ PropTokenEndpointAuthMethod, ++ PropIDTokenSigningAlg, ++ PropUserInfoSigningAlg, ++ PropRequiredClaims, + }, + Defaults: map[string]string{}, + }, +diff --git a/backend/internal/idp/utils.go b/backend/internal/idp/utils.go +--- a/backend/internal/idp/utils.go ++++ b/backend/internal/idp/utils.go +@@ -345,6 +345,20 @@ + requiredProps = teProps + } + } ++ if idpType == providers.IDPTypeOIDC { ++ if method, ok := filteredPropsMap[PropTokenEndpointAuthMethod]; ok { ++ value, err := method.GetValue() ++ if err != nil || value != "private_key_jwt" { ++ return nil, &ErrorInvalidIDPProperty ++ } ++ if _, exists := filteredPropsMap[PropClientSecret]; exists { ++ return nil, &ErrorInvalidIDPProperty ++ } ++ requiredProps = []string{PropClientID, PropRedirectURI, PropAuthorizationEndpoint, ++ PropTokenEndpoint, PropUserInfoEndpoint, PropJwksEndpoint, PropIssuer, ++ PropIDTokenSigningAlg, PropUserInfoSigningAlg, PropRequiredClaims} ++ } ++ } + for _, requiredProp := range requiredProps { + if !slices.Contains(filteredPropKeys, requiredProp) { + return nil, tidcommon.CustomServiceError(ErrorInvalidIDPProperty, tidcommon.I18nMessage{ +diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go +--- a/backend/internal/oauth/init.go ++++ b/backend/internal/oauth/init.go +@@ -56,6 +56,7 @@ + enforcementService revocation.EnforcementServiceInterface, + revocationSvc revocation.RevocationServiceInterface, + cfg oauthconfig.Config, ++ consentProvider providers.ConsentProvider, + ) (tokenservice.TokenValidatorInterface, error) { + jwks.Initialize(mux, runtimeCrypto) + httpClient := syshttp.NewHTTPClientWithCheckRedirect(func(req *http.Request, _ []*http.Request) error { +@@ -96,7 +97,7 @@ + grantHandlerProvider := granthandlers.Initialize( + jwtService, oauth2AuthzService, tokenBuilder, tokenValidator, + attributeCacheSvc, ouService, authzService, actorProvider, resourceService, +- cibaService, revocationSvc, revocationSvc, cfg) ++ cibaService, revocationSvc, revocationSvc, cfg, consentProvider) + + token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider, + scopeValidator, observabilitySvc, discoveryService, dpopVerifier, jtiStore, cfg) +diff --git a/backend/internal/oauth/oauth2/authz/service.go b/backend/internal/oauth/oauth2/authz/service.go +--- a/backend/internal/oauth/oauth2/authz/service.go ++++ b/backend/internal/oauth/oauth2/authz/service.go +@@ -408,6 +408,20 @@ + } + } + ++ if app.EntityCategory == providers.EntityCategoryAgent { ++ inbound, err := as.inboundClient.GetInboundClientByID(ctx, app.ID) ++ if providers.ValidateCitizenCodeClient(app) != nil || err != nil || inbound == nil || inbound.LoginConsent == nil || ++ inbound.LoginConsent.Delegation.Validate() != nil || inbound.LoginConsent.ValidityPeriod <= 0 || inbound.LoginConsent.ValidityPeriod > 300 || ++ len(oauthParams.Resources) != 1 || oauthParams.Resources[0] != inbound.LoginConsent.Delegation.Resource || len(oauthParams.PermissionScopes) == 0 { ++ return nil, &AuthorizationError{Code: oauth2const.ErrorInvalidRequest, Message: "Citizen agent authorization configuration or resource is invalid"} ++ } ++ for _, scope := range oauthParams.PermissionScopes { ++ if _, ok := inbound.LoginConsent.Delegation.ScopeFields[scope]; !ok { ++ return nil, &AuthorizationError{Code: oauth2const.ErrorInvalidScope, Message: "Scope has no governed consent field description"} ++ } ++ } ++ } ++ + // Initiate flow with OAuth context. + runtimeData := map[string]string{ + flowcm.RuntimeKeyClientID: oauthParams.ClientID, +@@ -422,7 +436,7 @@ + if effectiveAcrValues != "" { + runtimeData[flowcm.RuntimeKeyRequestedAuthClasses] = effectiveAcrValues + } +- if slices.Contains(strings.Fields(oauthParams.Prompt), oauth2const.PromptConsent) { ++ if app.EntityCategory == providers.EntityCategoryAgent || slices.Contains(strings.Fields(oauthParams.Prompt), oauth2const.PromptConsent) { + runtimeData[flowcm.RuntimeKeyForceConsentReprompt] = "true" + } + if oauthParams.MaxAge != "" { +diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go +--- a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go ++++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go +@@ -24,10 +24,12 @@ + + // authorizationCodeGrantHandler handles the authorization code grant type. + type authorizationCodeGrantHandler struct { +- authzService authz.AuthorizeServiceInterface +- tokenBuilder tokenservice.TokenBuilderInterface +- attributeCache attributecache.AttributeCacheServiceInterface +- resourceService providers.ResourceServerProvider ++ actorProvider providers.ActorProvider ++ consentValidator consentAuthorizationValidator ++ authzService authz.AuthorizeServiceInterface ++ tokenBuilder tokenservice.TokenBuilderInterface ++ attributeCache attributecache.AttributeCacheServiceInterface ++ resourceService providers.ResourceServerProvider + } + + // newAuthorizationCodeGrantHandler creates a new instance of AuthorizationCodeGrantHandler. +@@ -150,6 +152,18 @@ + accessTokenScopes = append(accessTokenScopes, downscopedNonOidc...) + } + ++ var consentBinding *providers.DelegationConsentBinding ++ if oauthApp.EntityCategory == providers.EntityCategoryAgent { ++ binding, err := h.validateCitizenConsent(ctx, oauthApp, authCode, attrs, accessTokenAudiences, accessTokenScopes) ++ if err != nil { ++ return nil, err ++ } ++ consentBinding = binding ++ stripCitizenAuthority(attrs) ++ } ++ delete(attrs, "registry_actor_kind") ++ delete(attrs, providers.DelegationConsentCacheKey) ++ delete(attrs, providers.DelegationConsentPolicyKey) + // Generate access token using tokenBuilder (attributes will be filtered in BuildAccessToken) + userSubConfig := oauthApp.UserAccessTokenConfig() + accessTokenCtx := &tokenservice.AccessTokenBuildContext{ +@@ -166,6 +180,26 @@ + ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), + DPoPJkt: dpop.GetJkt(ctx), + TokenFamilyID: authCode.TokenFamilyID, ++ } ++ if consentBinding != nil { ++ if accessTokenCtx.SubjectAttributes == nil { ++ accessTokenCtx.SubjectAttributes = make(map[string]interface{}) ++ } ++ accessTokenCtx.SubjectAttributes["registry_actor_kind"] = "agent" ++ accessTokenCtx.SubjectAttributes["registry_purpose"] = consentBinding.Policy.Purpose ++ if source := consentBinding.Policy.SubjectAttribute; source != "" { ++ accessTokenCtx.SubjectAttributes["registry_subject_person_reference"] = attrs[source] ++ } ++ if status := consentBinding.Policy.IdentityStatus; status != "" { ++ accessTokenCtx.SubjectAttributes["registry_identity_status"] = status ++ } ++ remaining := consentBinding.Deadline - time.Now().Unix() ++ if remaining <= 0 { ++ return nil, &model.ErrorResponse{Error: constants.ErrorInvalidGrant, ErrorDescription: "Citizen consent expired"} ++ } ++ if accessTokenCtx.ValidityPeriod > remaining { ++ accessTokenCtx.ValidityPeriod = remaining ++ } + } + if oauthApp.ShouldAppendActorClaim() { + accessTokenCtx.ActorClaims = &tokenservice.SubjectTokenClaims{Sub: oauthApp.ID} +diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go +--- a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go ++++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go +@@ -342,8 +342,7 @@ + includeActClaim bool + expectActor bool + }{ +- {name: "AgentClientAlwaysAppendsActor", entityCategory: providers.EntityCategoryAgent, +- includeActClaim: false, expectActor: true}, ++ + {name: "AppClientWithoutFlagOmitsActor", entityCategory: providers.EntityCategoryApp, + includeActClaim: false, expectActor: false}, + {name: "AppClientWithFlagAppendsActor", entityCategory: providers.EntityCategoryApp, +diff --git a/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go b/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go +@@ -0,0 +1,70 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package granthandlers ++ ++import ( ++ "context" ++ "encoding/json" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/authz" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++ "reflect" ++ "slices" ++ "strings" ++ "time" ++) ++ ++type consentAuthorizationValidator interface { ++ ValidateConsentAuthorization(context.Context, providers.DelegationConsentBinding) *tidcommon.ServiceError ++} ++ ++func (h *authorizationCodeGrantHandler) validateCitizenConsent(ctx context.Context, c *providers.OAuthClient, code *authz.AuthorizationCode, attrs map[string]interface{}, resources []string, scopes []string) (*providers.DelegationConsentBinding, *model.ErrorResponse) { ++ refusal := &model.ErrorResponse{Error: constants.ErrorInvalidGrant, ErrorDescription: "Fresh citizen consent is missing, withdrawn, expired, or changed"} ++ if providers.ValidateCitizenCodeClient(c) != nil || h.actorProvider == nil || h.consentValidator == nil { ++ return nil, refusal ++ } ++ inbound, err := h.actorProvider.GetInboundClientByID(ctx, c.ID) ++ if err != nil || inbound == nil || inbound.LoginConsent == nil || inbound.LoginConsent.Delegation.Validate() != nil { ++ return nil, refusal ++ } ++ raw, ok := attrs[providers.DelegationConsentCacheKey].(string) ++ if !ok || len(raw) > 64*1024 { ++ return nil, refusal ++ } ++ var binding providers.DelegationConsentBinding ++ if json.Unmarshal([]byte(raw), &binding) != nil || binding.Subject != code.AuthorizedUserID || binding.AppID != c.ID || binding.AuthorizationID == "" || ++ binding.ValidityPeriod != inbound.LoginConsent.ValidityPeriod || binding.ValidityPeriod <= 0 || binding.ValidityPeriod > 300 || binding.Deadline <= time.Now().Unix() || !reflect.DeepEqual(binding.Policy, *inbound.LoginConsent.Delegation) || len(resources) != 1 || resources[0] != binding.Policy.Resource || len(binding.Scopes) == 0 { ++ return nil, refusal ++ } ++ for _, scope := range scopes { ++ if _, oidc := constants.StandardOIDCScopes[scope]; !oidc && !slices.Contains(binding.Scopes, scope) { ++ return nil, refusal ++ } ++ } ++ for key := range attrs { ++ if key != providers.DelegationConsentCacheKey && !slices.Contains(binding.Attributes, key) { ++ delete(attrs, key) ++ } ++ } ++ if name := binding.Policy.SubjectAttribute; name != "" { ++ value, ok := attrs[name].(string) ++ if !ok || strings.TrimSpace(value) == "" || len(value) > 1024 || !slices.Contains(binding.Attributes, name) { ++ return nil, refusal ++ } ++ } ++ if h.consentValidator.ValidateConsentAuthorization(ctx, binding) != nil { ++ return nil, refusal ++ } ++ return &binding, nil ++} ++ ++func stripCitizenAuthority(attrs map[string]interface{}) { ++ for key := range attrs { ++ if strings.HasPrefix(key, "registry_grant_") || key == "registry_approver" || key == "identity" || key == "registry_subject_person_reference" || key == "registry_identity_status" || key == providers.DelegationConsentCacheKey || key == providers.DelegationConsentPolicyKey { ++ delete(attrs, key) ++ } ++ } ++} +diff --git a/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go b/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go +new file mode 100644 +--- /dev/null ++++ b/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go +@@ -0,0 +1,148 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++package granthandlers ++ ++import ( ++ "context" ++ "encoding/json" ++ "github.com/stretchr/testify/mock" ++ "github.com/stretchr/testify/require" ++ "github.com/thunder-id/thunderid/internal/attributecache" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/authz" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" ++ "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" ++ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" ++ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ++ "testing" ++ "time" ++) ++ ++type citizenActor struct { ++ providers.ActorProvider ++ inbound *providers.InboundClient ++} ++ ++func (a citizenActor) GetInboundClientByID(context.Context, string) (*providers.InboundClient, *tidcommon.ServiceError) { ++ return a.inbound, nil ++} ++ ++type citizenConsent struct{ failed bool } ++ ++func (c citizenConsent) ValidateConsentAuthorization(context.Context, providers.DelegationConsentBinding) *tidcommon.ServiceError { ++ if c.failed { ++ return &tidcommon.InternalServerError ++ } ++ return nil ++} ++ ++func citizenFixture() (*authorizationCodeGrantHandler, *providers.OAuthClient, *authz.AuthorizationCode, providers.DelegationConsentBinding) { ++ policy := providers.DelegationConsentConfig{SubjectAttribute: "person_reference", IdentityStatus: "active", Purpose: "Read eligibility", Resource: "urn:registry:citizen", ScopeFields: map[string][]string{"records:read": {"eligibility"}}} ++ client := &providers.OAuthClient{ID: "native-agent-entity", ClientID: "oauth-client", EntityCategory: providers.EntityCategoryAgent, ++ PKCERequired: true, GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, ResponseTypes: []providers.ResponseType{providers.ResponseTypeCode}, RedirectURIs: []string{"https://agent.example/callback"}, ++ Token: &providers.OAuthTokenConfig{AccessToken: &providers.AccessTokenConfig{UserConfig: &providers.AccessTokenSubConfig{ValidityPeriod: 300, Attributes: []string{"person_reference"}}}}} ++ binding := providers.DelegationConsentBinding{ConsentID: "consent", AuthorizationID: "fresh-authorization", UserID: "native-citizen", Subject: "pairwise-citizen", AppID: client.ID, ValidityPeriod: 300, Deadline: time.Now().Unix() + 120, Policy: policy, Scopes: []string{"records:read"}, Attributes: []string{"person_reference"}} ++ h := &authorizationCodeGrantHandler{actorProvider: citizenActor{inbound: &providers.InboundClient{LoginConsent: &providers.LoginConsentConfig{Delegation: &policy, ValidityPeriod: 300}}}, consentValidator: citizenConsent{}} ++ return h, client, &authz.AuthorizationCode{AuthorizedUserID: "pairwise-citizen"}, binding ++} ++func TestCitizenCodeRequiresTrustedCurrentBinding(t *testing.T) { ++ for _, scenario := range []string{"ok", "missing-cache", "forged-json", "other-subject", "other-agent", "expired", "purpose-changed", "fields-changed", "resource-changed", "scope-increase", "lookup-failed", "mixed-grants", "refresh", "pkce-disabled", "long-token", "grant-attributes", "missing-person-reference", "status-policy-changed", "duration-policy-changed"} { ++ t.Run(scenario, func(t *testing.T) { ++ h, c, code, binding := citizenFixture() ++ resources := []string{binding.Policy.Resource} ++ scopes := []string{"openid", "records:read"} ++ switch scenario { ++ case "duration-policy-changed": ++ binding.ValidityPeriod = 30 ++ case "status-policy-changed": ++ binding.Policy.IdentityStatus = "inactive" ++ case "other-subject": ++ code.AuthorizedUserID = "other-citizen" ++ case "other-agent": ++ c.ID = "other-agent" ++ case "expired": ++ binding.Deadline = time.Now().Unix() - 1 ++ case "purpose-changed": ++ binding.Policy.Purpose = "unapproved purpose" ++ case "fields-changed": ++ binding.Policy.ScopeFields = map[string][]string{"records:read": {"name", "address"}} ++ case "resource-changed": ++ resources = []string{"urn:other"} ++ case "scope-increase": ++ scopes = append(scopes, "records:write") ++ case "lookup-failed": ++ h.consentValidator = citizenConsent{failed: true} ++ case "mixed-grants": ++ c.GrantTypes = append(c.GrantTypes, providers.GrantTypeClientCredentials) ++ case "refresh": ++ c.GrantTypes = append(c.GrantTypes, providers.GrantTypeRefreshToken) ++ case "pkce-disabled": ++ c.PKCERequired = false ++ case "long-token": ++ c.Token.AccessToken.UserConfig.ValidityPeriod = 301 ++ case "grant-attributes": ++ c.Token.AccessToken.UserConfig.Attributes = append(c.Token.AccessToken.UserConfig.Attributes, "registry_grant_id") ++ } ++ raw, _ := json.Marshal(binding) ++ attrs := map[string]interface{}{providers.DelegationConsentCacheKey: string(raw), "person_reference": "synthetic-ref", "unconsented": "hidden"} ++ if scenario == "missing-person-reference" { ++ delete(attrs, "person_reference") ++ } ++ if scenario == "missing-cache" { ++ delete(attrs, providers.DelegationConsentCacheKey) ++ } ++ if scenario == "forged-json" { ++ attrs[providers.DelegationConsentCacheKey] = map[string]string{"AuthorizationID": "forged"} ++ } ++ result, err := h.validateCitizenConsent(context.Background(), c, code, attrs, resources, scopes) ++ if scenario == "ok" { ++ require.Nil(t, err) ++ require.NotNil(t, result) ++ require.NotContains(t, attrs, "unconsented") ++ } else { ++ require.NotNil(t, err) ++ require.Nil(t, result) ++ } ++ }) ++ } ++} ++ ++func (suite *AuthorizationCodeGrantHandlerTestSuite) TestCitizenAgentEmitsOnlyConsentedAuthority() { ++ base, c, _, binding := citizenFixture() ++ c.ClientID = testClientID ++ c.RedirectURIs = []string{testClientCallbackURL} ++ c.Token.AccessToken.UserConfig.Attributes = []string{"person_reference", "registry_actor_kind", "registry_purpose"} ++ suite.handler.actorProvider = base.actorProvider ++ suite.handler.consentValidator = base.consentValidator ++ suite.testAuthzCode.AuthorizedUserID = binding.Subject ++ suite.testAuthzCode.Resources = []string{binding.Policy.Resource} ++ suite.testAuthzCode.Scopes = "records:read" ++ suite.testAuthzCode.CodeChallenge = testCodeChallenge ++ suite.testAuthzCode.CodeChallengeMethod = "S256" ++ suite.testAuthzCode.AttributeCacheID = "citizen-cache" ++ suite.testTokenReq.Resources = []string{binding.Policy.Resource} ++ suite.testTokenReq.CodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" ++ raw, _ := json.Marshal(binding) ++ attrs := map[string]interface{}{providers.DelegationConsentCacheKey: string(raw), "person_reference": "synthetic-ref", "registry_actor_kind": "human", "registry_purpose": "forged", "registry_grant_id": "forged", "registry_subject_person_reference": "forged", "registry_identity_status": "forged", "identity": map[string]string{"other": "forged"}} ++ suite.mockAttrCacheService.On("GetAttributeCache", mock.Anything, "citizen-cache").Return(&attributecache.AttributeCache{Attributes: attrs}, nil) ++ suite.mockAuthzService.On("GetAuthorizationCodeDetails", mock.Anything, testClientID, "test-auth-code").Return(&suite.testAuthzCode, nil) ++ var captured *tokenservice.AccessTokenBuildContext ++ suite.mockTokenBuilder.On("BuildAccessToken", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { captured = args.Get(1).(*tokenservice.AccessTokenBuildContext) }).Return(&model.TokenDTO{Token: "synthetic-access", TokenType: constants.TokenTypeBearer}, nil) ++ result, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, c) ++ suite.Nil(err) ++ suite.NotNil(result) ++ suite.NotNil(captured) ++ suite.Equal(binding.Subject, captured.Subject) ++ suite.Equal(c.ID, captured.ActorClaims.Sub) ++ suite.Equal(testClientID, captured.ClientID) ++ suite.Equal("agent", captured.SubjectAttributes["registry_actor_kind"]) ++ suite.Equal(binding.Policy.Purpose, captured.SubjectAttributes["registry_purpose"]) ++ suite.Equal("synthetic-ref", captured.SubjectAttributes["person_reference"]) ++ suite.Equal("synthetic-ref", captured.SubjectAttributes["registry_subject_person_reference"]) ++ suite.Equal("active", captured.SubjectAttributes["registry_identity_status"]) ++ suite.NotContains(captured.SubjectAttributes, "registry_grant_id") ++ suite.NotContains(captured.SubjectAttributes, "identity") ++ suite.NotContains(captured.SubjectAttributes, providers.DelegationConsentCacheKey) ++ suite.LessOrEqual(captured.ValidityPeriod, int64(120)) ++ suite.Greater(captured.ValidityPeriod, int64(0)) ++} +diff --git a/backend/internal/oauth/oauth2/granthandlers/init.go b/backend/internal/oauth/oauth2/granthandlers/init.go +--- a/backend/internal/oauth/oauth2/granthandlers/init.go ++++ b/backend/internal/oauth/oauth2/granthandlers/init.go +@@ -31,8 +31,9 @@ + refreshTokenRevoker revocation.RefreshTokenRevokerInterface, + criteriaRevoker revocation.CriteriaRevokerInterface, + cfg oauthconfig.Config, ++ consentProvider providers.ConsentProvider, + ) GrantHandlerProviderInterface { +- return newGrantHandlerProvider( ++ result := newGrantHandlerProvider( + jwtService, + oauth2AuthzService, + tokenBuilder, +@@ -47,4 +48,11 @@ + criteriaRevoker, + cfg, + ) ++ if p, ok := result.(*GrantHandlerProvider); ok { ++ if h, ok := p.authorizationCodeGrantHandler.(*authorizationCodeGrantHandler); ok { ++ h.actorProvider = actorProvider ++ h.consentValidator, _ = consentProvider.(consentAuthorizationValidator) ++ } ++ } ++ return result + } +diff --git a/backend/internal/oauth/oauth2/token/service.go b/backend/internal/oauth/oauth2/token/service.go +--- a/backend/internal/oauth/oauth2/token/service.go ++++ b/backend/internal/oauth/oauth2/token/service.go +@@ -116,6 +116,9 @@ + } + } + ++ if oauthApp.EntityCategory == providers.EntityCategoryAgent && oauthApp.IsAllowedGrantType(providers.GrantTypeAuthorizationCode) && providers.ValidateCitizenCodeClient(oauthApp) != nil { ++ return nil, &model.ErrorResponse{Error: constants.ErrorUnauthorizedClient, ErrorDescription: "Citizen agents require a closed authorization-code configuration"} ++ } + // Validate grant type against the application. + if !oauthApp.IsAllowedGrantType(grantType) { + publishTokenIssuanceFailedEvent(ts.observabilitySvc, ctx, clientID, grantTypeStr, scopeStr, +diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go +--- a/backend/pkg/thunderidengine/engine.go ++++ b/backend/pkg/thunderidengine/engine.go +@@ -221,7 +221,7 @@ + engineCtx.jweService, engineCtx.flowExecService, engineCtx.observabilitySvc, engineCtx.runtimeCryptoSvc, + engineCtx.ouProvider, engineCtx.attributeCacheService, engineCtx.authzProvider, engineCtx.resourceProvider, + engineCtx.i18nProvider, engineCtx.idpProvider, engineCtx.dpopVerifier, engineCtx.runtimeStoreProvider, +- engineCtx.transactioner, revocationEnforcer, revocationService, oauthConfig) ++ engineCtx.transactioner, revocationEnforcer, revocationService, oauthConfig, engineCtx.consentProvider) + if err != nil { + logger.Fatal(ctx, "Failed to initialize OAuth services", log.Error(err)) + } +diff --git a/backend/pkg/thunderidengine/providers/delegation.go b/backend/pkg/thunderidengine/providers/delegation.go +new file mode 100644 +--- /dev/null ++++ b/backend/pkg/thunderidengine/providers/delegation.go +@@ -0,0 +1,92 @@ ++// Copyright 2026 The ThunderID Authors ++// SPDX-License-Identifier: Apache-2.0 ++ ++package providers ++ ++import ( ++ "errors" ++ "net/url" ++ "slices" ++ "strings" ++) ++ ++// DelegationConsentConfig describes the governed downstream use shown to a ++// citizen. ScopeFields lists the exact fields each admitted permission exposes. ++type DelegationConsentConfig struct { ++ SubjectAttribute string `json:"subjectAttribute,omitempty" yaml:"subjectAttribute,omitempty"` ++ IdentityStatus string `json:"identityStatus,omitempty" yaml:"identityStatus,omitempty"` ++ Purpose string `json:"purpose" yaml:"purpose"` ++ Resource string `json:"resource" yaml:"resource"` ++ ScopeFields map[string][]string `json:"scopeFields" yaml:"scopeFields"` ++} ++ ++// DelegationConsentBinding is server-only flow/cache state, never a user claim. ++// AuthorizationID changes on every native consent update, including reapproval. ++type DelegationConsentBinding struct { ++ ConsentID string ++ AuthorizationID string ++ AppID string ++ UserID string ++ Subject string ++ ValidityPeriod int64 ++ Deadline int64 ++ Policy DelegationConsentConfig ++ Scopes []string ++ Attributes []string ++} ++ ++const DelegationConsentCacheKey = "__registry_citizen_consent" ++const DelegationConsentPolicyKey = "__registry_citizen_policy" ++ ++func (c *DelegationConsentConfig) Validate() error { ++ invalid := errors.New("citizen delegation requires a purpose, resource, and explicit scope field descriptions") ++ if c == nil || strings.TrimSpace(c.Purpose) == "" || len(c.Purpose) > 1024 || len(c.ScopeFields) == 0 || len(c.ScopeFields) > 64 { ++ return invalid ++ } ++ if len(c.SubjectAttribute) > 128 || strings.ContainsAny(c.SubjectAttribute, " \t\r\n") || strings.HasPrefix(c.SubjectAttribute, "registry_") || strings.HasPrefix(c.SubjectAttribute, "_") || c.IdentityStatus != "" && c.IdentityStatus != "active" { ++ return invalid ++ } ++ resource, err := url.Parse(c.Resource) ++ if err != nil || resource.Scheme == "" || resource.Fragment != "" || resource.User != nil { ++ return invalid ++ } ++ for scope, fields := range c.ScopeFields { ++ if scope == "" || len(scope) > 256 || strings.ContainsAny(scope, " \t\r\n") || len(fields) == 0 || len(fields) > 64 { ++ return invalid ++ } ++ seen := map[string]bool{} ++ for _, field := range fields { ++ if field == "" || len(field) > 256 || strings.ContainsAny(field, "\r\n") || seen[field] { ++ return invalid ++ } ++ seen[field] = true ++ } ++ } ++ return nil ++} ++ ++// ValidateCitizenCodeClient also runs at authorization and redemption, so a ++// changed registration cannot unlock another issuance path for a citizen client. ++func ValidateCitizenCodeClient(c *OAuthClient) error { ++ invalid := errors.New("citizen agents require authorization_code only, PKCE, exact redirects, and at most 300 second access tokens") ++ if c == nil || c.EntityCategory != EntityCategoryAgent || c.ID == "" || len(c.GrantTypes) != 1 || c.GrantTypes[0] != GrantTypeAuthorizationCode || ++ !c.PKCERequired || len(c.ResponseTypes) != 1 || c.ResponseTypes[0] != ResponseTypeCode || len(c.RedirectURIs) == 0 { ++ return invalid ++ } ++ cfg := c.UserAccessTokenConfig() ++ if cfg == nil || cfg.ValidityPeriod <= 0 || cfg.ValidityPeriod > 300 { ++ return invalid ++ } ++ for _, uri := range c.RedirectURIs { ++ u, err := url.Parse(uri) ++ if err != nil || u.Scheme == "" || u.Fragment != "" || u.User != nil || strings.Contains(uri, "*") { ++ return invalid ++ } ++ } ++ for _, attr := range cfg.Attributes { ++ if strings.HasPrefix(attr, "registry_grant_") || slices.Contains([]string{"registry_approver", DelegationConsentCacheKey, DelegationConsentPolicyKey}, attr) { ++ return invalid ++ } ++ } ++ return nil ++} +diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go +--- a/backend/pkg/thunderidengine/providers/model.go ++++ b/backend/pkg/thunderidengine/providers/model.go +@@ -722,7 +722,8 @@ + + // LoginConsentConfig is the login consent configuration. + type LoginConsentConfig struct { +- ValidityPeriod int64 `json:"validityPeriod" yaml:"validityPeriod" jsonschema:"Consent validity period in seconds. 0 means never expire."` ++ Delegation *DelegationConsentConfig `json:"delegation,omitempty" yaml:"delegation,omitempty"` ++ ValidityPeriod int64 `json:"validityPeriod" yaml:"validityPeriod" jsonschema:"Consent validity period in seconds. 0 means never expire."` + } + + // Entity represents a unified identity principal returned by the entity provider. +diff --git a/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx b/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx +--- a/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx ++++ b/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx +@@ -116,6 +116,16 @@ + const isExpiredOnMount = hasTimer && expiresIn <= 0; + + if (hasConsent) { ++ const findSubmit = (items: FlowComponent[]): FlowComponent | undefined => { ++ for (const item of items) { ++ if (item.eventType?.toUpperCase() === 'SUBMIT') return item; ++ const child = findSubmit((item.components ?? []) as FlowComponent[]); ++ if (child) return child; ++ } ++ return undefined; ++ }; ++ const withdrawalAction = findSubmit([comp]); ++ const canWithdraw = additionalData?.['citizenConsentWithdrawal'] === 'true' && withdrawalAction != null; + return ( + <> + onSubmit(withdrawalAction!, {...values, __consent_reason__: 'withdrawal'}) : undefined ++ } + /> + { ++ it('submits withdrawal through the native flow only when the server exposes it', () => { ++ const action = {type: 'ACTION', id: 'allow', eventType: 'SUBMIT', label: 'Allow'}; ++ const component = { ++ type: 'BLOCK', ++ id: 'consent', ++ components: [{type: 'CONSENT_INPUT', id: 'input'}, action], ++ } as unknown as EmbeddedFlowComponent; ++ const onSubmit = vi.fn(); ++ renderWithProviders( ++ , ++ ); ++ fireEvent.click(screen.getByRole('button', {name: 'Withdraw this agent’s access'})); ++ expect(onSubmit).toHaveBeenCalledWith(action, {__consent_reason__: 'withdrawal'}); ++ }); ++}); +diff --git a/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx b/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx +--- a/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx ++++ b/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx +@@ -9,7 +9,7 @@ + type ConsentRenderProps, + } from '@thunderid/react'; + import {cn} from '@thunderid/utils'; +-import {Box, Divider, FormControlLabel, Switch, Typography} from '@wso2/oxygen-ui'; ++import {Box, Button, Divider, FormControlLabel, Switch, Typography} from '@wso2/oxygen-ui'; + import type {JSX} from 'react'; + + function isPermissionPurpose(purpose: ConsentPurpose): boolean { +@@ -43,6 +43,8 @@ + + interface ConsentAdapterProps { + consentData?: string | ConsentPurpose[] | {purposes: ConsentPurpose[]}; ++ onWithdraw?: () => void; ++ disabled?: boolean; + formValues: Record; + onInputChange: (name: string, value: string) => void; + } +@@ -140,6 +142,8 @@ + consentData = undefined, + formValues, + onInputChange, ++ onWithdraw, ++ disabled = false, + }: ConsentAdapterProps): JSX.Element | null { + if (!consentData) return null; + +@@ -147,8 +151,24 @@ + + {({purposes}: ConsentRenderProps) => ( + ++ {onWithdraw && ( ++ ++ ++ Withdrawing ends this agent's current consent. Declining the new request leaves earlier consent ++ unchanged. Already issued access may remain valid for up to five minutes. ++ ++ ++ ++ )} + {purposes.map((purpose, idx) => ( + ++ {purpose.description && ( ++ ++ {purpose.description} ++ ++ )} + {isPermissionPurpose(purpose) && purpose.optional && purpose.optional.length > 0 && ( + + +diff --git a/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx b/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx +--- a/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx ++++ b/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx +@@ -109,3 +109,38 @@ + expect(switchFor('users').checked).toBe(true); + }); + }); ++ ++describe('ConsentAdapter purpose disclosure', () => { ++ it('shows the governed agent purpose, destination, fields and duration', () => { ++ const description = ++ 'Eligibility agent may use the citizen registry for eligibility. Fields: eligibility. Access lasts at most 300 seconds.'; ++ const consentData = JSON.stringify([ ++ { ++ purposeName: 'permissions:agent', ++ purposeId: 'agent', ++ type: 'permissions', ++ description, ++ essential: [], ++ optional: [{name: 'records:read'}], ++ }, ++ ]); ++ renderWithProviders(); ++ expect(screen.getByText(description)).toBeInTheDocument(); ++ }); ++}); ++ ++describe('Citizen withdrawal', () => { ++ it('offers explicit withdrawal separately from consent choices', () => { ++ const onWithdraw = vi.fn(); ++ renderWithProviders( ++ , ++ ); ++ fireEvent.click(screen.getByRole('button', {name: 'Withdraw this agent’s access'})); ++ expect(onWithdraw).toHaveBeenCalledOnce(); ++ expect(screen.getByText(/Already issued access may remain valid/)).toBeTruthy(); ++ }); ++ it('does not offer withdrawal without server authorization', () => { ++ renderWithProviders(); ++ expect(screen.queryByRole('button', {name: 'Withdraw this agent’s access'})).toBeNull(); ++ }); ++}); diff --git a/products/identity/thunderid/extension/test_build.py b/products/identity/thunderid/extension/test_build.py new file mode 100644 index 0000000000..d0cd24f5a7 --- /dev/null +++ b/products/identity/thunderid/extension/test_build.py @@ -0,0 +1,68 @@ +"""Source preparation must not trust a pre-extracted cache or archive paths.""" + +import io +from pathlib import Path +import tarfile +import tempfile +import unittest +import subprocess + +import build + + +class SourcePreparationTests(unittest.TestCase): + def test_applies_patch_inside_an_existing_parent_repository(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + subprocess.run(["git", "init", "--quiet", str(root)], check=True) + source = root / "target/upstream" + source.mkdir(parents=True) + (source / "example").write_text("before\n") + patch = root / "change.patch" + patch.write_text("diff --git a/example b/example\n--- a/example\n+++ b/example\n@@ -1 +1 @@\n-before\n+after\n") + build.apply_source_patch(source, patch) + self.assertEqual((source / "example").read_text(), "after\n") + + def archive(self, root, name="source/backend/main.go", kind=tarfile.REGTYPE): + archive = root / "source.tar.gz" + with tarfile.open(archive, "w:gz") as output: + member = tarfile.TarInfo(name) + member.type = kind + member.mode = 0o644 + member.size = 3 if kind == tarfile.REGTYPE else 0 + member.linkname = "../../outside" + output.addfile(member, io.BytesIO(b"pkg") if member.size else None) + return archive + + def test_extracts_verified_source_and_preserves_existing_destination(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = self.archive(root) + destination = root / "out" + build.extract_source(archive, destination, build.sha256(archive)) + self.assertEqual((destination / "backend/main.go").read_bytes(), b"pkg") + with self.assertRaises(FileExistsError): + build.extract_source(archive, destination, build.sha256(archive)) + + def test_refuses_checksum_mismatch_before_extracting(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = self.archive(root) + with self.assertRaisesRegex(ValueError, "checksum mismatch"): + build.extract_source(archive, root / "out", "0" * 64) + self.assertFalse((root / "out").exists()) + + def test_refuses_archive_escape_and_links(self): + for name, kind in [("source/../../outside", tarfile.REGTYPE), + ("/absolute", tarfile.REGTYPE), + ("source/link", tarfile.SYMTYPE)]: + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = self.archive(root, name, kind) + with self.assertRaisesRegex(ValueError, "unsupported"): + build.extract_source(archive, root / "out", build.sha256(archive)) + self.assertFalse((root / "out").exists()) + + +if __name__ == "__main__": + unittest.main() From dfbe3ad2679051ab727e1a69c60f02ef88c48fda Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:50 +0700 Subject: [PATCH 003/120] feat(auth): checkpoint contextual claims and stock issuer exchange Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- Cargo.lock | 35 +- Cargo.toml | 2 + crates/registry-platform-audit/README.md | 5 + .../src/authorization.rs | 352 +++++++ crates/registry-platform-audit/src/lib.rs | 3 + crates/registry-platform-httputil/README.md | 6 +- .../src/client/mod.rs | 8 +- .../src/client/private_key_jwt.rs | 848 +++++++++++++++- .../src/client/token.rs | 15 +- crates/registry-platform-httputil/src/lib.rs | 11 +- crates/registry-platform-oidc/README.md | 6 + .../src/authorization_claims.rs | 871 ++++++++++++++++ crates/registry-platform-oidc/src/lib.rs | 40 +- crates/registry-thunderid-tooling/Cargo.toml | 35 + crates/registry-thunderid-tooling/README.md | 111 +++ .../examples/contextual-exchange.rs | 937 ++++++++++++++++++ .../examples/integration.rs | 678 +++++++++++++ .../examples/render-fixture.rs | 27 + .../fixtures/default-agent-type-v1.0.1.yaml | 32 + .../src/bootstrap.rs | 273 +++++ .../src/container.rs | 559 +++++++++++ .../src/description.rs | 619 ++++++++++++ .../registry-thunderid-tooling/src/issuer.rs | 70 ++ crates/registry-thunderid-tooling/src/lib.rs | 67 ++ .../registry-thunderid-tooling/src/local.rs | 332 +++++++ .../src/local_session.rs | 411 ++++++++ .../registry-thunderid-tooling/src/render.rs | 592 +++++++++++ .../registry-thunderid-tooling/src/testing.rs | 91 ++ .../registry-thunderid-tooling/src/version.rs | 117 +++ .../thunderid-version.json | 10 + .../scripts/test-contextual-exchange.py | 46 + .../scripts/test-thunderid-integration.py | 188 ++++ .../registry-schema/agent-type.yaml | 36 + .../0197aaaa-0000-7000-8000-0000000000a1.yaml | 25 + .../0197aaaa-0000-7000-8000-0000000000a2.yaml | 19 + .../0197aaaa-0000-7000-8000-0000000000b1.yaml | 16 + .../0197aaaa-0000-7000-8000-0000000000c1.yaml | 12 + .../secrets/compatibility-client-secret | 1 + 38 files changed, 7474 insertions(+), 32 deletions(-) create mode 100644 crates/registry-platform-audit/src/authorization.rs create mode 100644 crates/registry-platform-oidc/src/authorization_claims.rs create mode 100644 crates/registry-thunderid-tooling/Cargo.toml create mode 100644 crates/registry-thunderid-tooling/README.md create mode 100644 crates/registry-thunderid-tooling/examples/contextual-exchange.rs create mode 100644 crates/registry-thunderid-tooling/examples/integration.rs create mode 100644 crates/registry-thunderid-tooling/examples/render-fixture.rs create mode 100644 crates/registry-thunderid-tooling/fixtures/default-agent-type-v1.0.1.yaml create mode 100644 crates/registry-thunderid-tooling/src/bootstrap.rs create mode 100644 crates/registry-thunderid-tooling/src/container.rs create mode 100644 crates/registry-thunderid-tooling/src/description.rs create mode 100644 crates/registry-thunderid-tooling/src/issuer.rs create mode 100644 crates/registry-thunderid-tooling/src/lib.rs create mode 100644 crates/registry-thunderid-tooling/src/local.rs create mode 100644 crates/registry-thunderid-tooling/src/local_session.rs create mode 100644 crates/registry-thunderid-tooling/src/render.rs create mode 100644 crates/registry-thunderid-tooling/src/testing.rs create mode 100644 crates/registry-thunderid-tooling/src/version.rs create mode 100644 crates/registry-thunderid-tooling/thunderid-version.json create mode 100755 products/identity/scripts/test-contextual-exchange.py create mode 100755 products/identity/scripts/test-thunderid-integration.py create mode 100644 products/identity/thunderid/fixtures/synthetic-session/registry-schema/agent-type.yaml create mode 100644 products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a1.yaml create mode 100644 products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a2.yaml create mode 100644 products/identity/thunderid/fixtures/synthetic-session/resources/resource_servers/0197aaaa-0000-7000-8000-0000000000b1.yaml create mode 100644 products/identity/thunderid/fixtures/synthetic-session/resources/roles/0197aaaa-0000-7000-8000-0000000000c1.yaml create mode 100644 products/identity/thunderid/fixtures/synthetic-session/secrets/compatibility-client-secret diff --git a/Cargo.lock b/Cargo.lock index 8d0748bd13..36974dece4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3497,6 +3497,7 @@ dependencies = [ "registry-platform-canonical-json", "registry-platform-crypto", "registry-platform-httputil", + "registry-thunderid-tooling", "reqwest", "rustix 1.1.4", "serde", @@ -3508,6 +3509,7 @@ dependencies = [ "thiserror 2.0.20", "time", "tokio", + "url", "uuid", "zeroize", ] @@ -3518,6 +3520,7 @@ version = "0.30.0" dependencies = [ "async-trait", "axum", + "base64", "chrono", "clap", "deadpool-postgres", @@ -3525,6 +3528,7 @@ dependencies = [ "http", "jsonschema", "jsonwebtoken", + "registry-breg", "registry-casework-breg", "registry-casework-client", "registry-casework-core", @@ -3533,10 +3537,13 @@ dependencies = [ "registry-platform-buildinfo", "registry-platform-canonical-json", "registry-platform-config", + "registry-platform-crypto", "registry-platform-httpsec", "registry-platform-httputil", "registry-platform-oidc", "registry-platform-testing", + "registry-thunderid-tooling", + "reqwest", "rustls", "schemars", "serde", @@ -3656,6 +3663,9 @@ dependencies = [ "registry-casework-core", "registry-platform-buildinfo", "registry-platform-config", + "registry-platform-crypto", + "registry-platform-httputil", + "registry-thunderid-tooling", "reqwest", "rustix 1.1.4", "serde", @@ -3895,11 +3905,11 @@ dependencies = [ "p256", "registry-evidence", "registry-evidence-verifier", - "registry-mint", "registry-platform-crypto", "registry-platform-httpsec", "registry-platform-httputil", "registry-platform-sdjwt", + "registry-thunderid-tooling", "reqwest", "serde", "serde_json", @@ -3973,12 +3983,12 @@ dependencies = [ "p256", "registry-evidence-client", "registry-evidence-verifier", - "registry-mint", "registry-platform-buildinfo", "registry-platform-crypto", "registry-platform-httputil", "registry-platform-oidc", "registry-platform-sdjwt", + "registry-thunderid-tooling", "reqwest", "rustix 1.1.4", "serde", @@ -4036,6 +4046,7 @@ dependencies = [ "registry-language-server", "registry-platform-buildinfo", "registry-platform-crypto", + "registry-thunderid-tooling", "rhai", "rustix 1.1.4", "serde", @@ -4514,6 +4525,26 @@ dependencies = [ "registry-relay-client", ] +[[package]] +name = "registry-thunderid-tooling" +version = "0.30.0" +dependencies = [ + "base64", + "getrandom 0.4.3", + "p256", + "registry-platform-authcommon", + "registry-platform-crypto", + "registry-platform-httputil", + "reqwest", + "serde", + "serde_json", + "serde_norway", + "sha2 0.11.0", + "thiserror 2.0.20", + "tokio", + "url", +] + [[package]] name = "reqwest" version = "0.12.28" diff --git a/Cargo.toml b/Cargo.toml index d8bbef96eb..ed281b5da9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ members = [ "crates/registry-platform-sdjwt", "crates/registry-platform-sqlite", "crates/registry-platform-testing", + "crates/registry-thunderid-tooling", "crates/registry-manifest-core", "crates/registry-manifest-cli", "crates/registry-mint", @@ -71,6 +72,7 @@ repository = "https://github.com/registrystack/registry-stack" unsafe_code = "forbid" [workspace.dependencies] +registry-thunderid-tooling = { path = "crates/registry-thunderid-tooling", version = "0.30.0" } registry-casework = { path = "crates/registry-casework", version = "0.30.0" } registry-casework-client-node = { path = "crates/registry-casework-client-node", version = "0.30.0" } registry-casework-client-py = { path = "crates/registry-casework-client-py", version = "0.30.0" } diff --git a/crates/registry-platform-audit/README.md b/crates/registry-platform-audit/README.md index 58843e5eca..ba84b03b6e 100644 --- a/crates/registry-platform-audit/README.md +++ b/crates/registry-platform-audit/README.md @@ -21,6 +21,8 @@ helpers for registry services. handles whose service-owned canonical input stays outside the platform domain. - `AuditKeyHasher::sensitive_value_hash` for generic field-bound audit lookup values used by redaction helpers. +- `AuthorizationAuditEvent` for one privacy-safe authorization event shape + across products, using pseudonyms from each product's existing audit profile. ## Typical Use @@ -85,6 +87,9 @@ async fn write_audit_event() -> Result<(), registry_platform_audit::AuditError> - Use `AuditKeyHasher::audit_reference_hash` for audit references instead of concatenating ad hoc hash inputs in each service. Keep service semantics and canonicalization in the consuming service. +- `AuthorizationAuditEvent` accepts only platform hash handles and Evidence's + established key-versioned pseudonyms for identity-bearing fields. It does + not derive keys or replace a product's pseudonym scope policy. - Redaction helpers intentionally avoid preserving email local parts, phone digits, or sensitive query values. diff --git a/crates/registry-platform-audit/src/authorization.rs b/crates/registry-platform-audit/src/authorization.rs new file mode 100644 index 0000000000..895612b8c0 --- /dev/null +++ b/crates/registry-platform-audit/src/authorization.rs @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared, privacy-safe authorization audit event fields. + +use std::fmt; + +use serde::Serialize; +use thiserror::Error; + +const MAX_CODE_BYTES: usize = 128; + +/// The authorization decision represented by an audit event. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorizationOutcome { + Allowed, + Denied, +} + +/// Common authorization audit fields filled by each product. +/// +/// Identity-bearing values must be pseudonyms produced with the product's +/// existing [`crate::AuditKeyHasher`] profile. Evidence's key-versioned +/// pseudonyms are accepted as well, so consumers do not create a second key +/// system to use this shape. +#[derive(Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizationAuditEvent { + actor_kind: String, + principal_pseudonym: String, + client_pseudonym: String, + #[serde(skip_serializing_if = "Option::is_none")] + grant_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + purpose: Option, + operation: String, + outcome: AuthorizationOutcome, + reason: String, +} + +impl AuthorizationAuditEvent { + #[allow(clippy::too_many_arguments)] + pub fn new( + actor_kind: impl Into, + principal_pseudonym: impl Into, + client_pseudonym: impl Into, + grant_pseudonym: Option, + purpose: impl Into, + operation: impl Into, + outcome: AuthorizationOutcome, + reason: impl Into, + ) -> Result { + let event = Self { + actor_kind: actor_kind.into(), + principal_pseudonym: principal_pseudonym.into(), + client_pseudonym: client_pseudonym.into(), + grant_pseudonym, + purpose: Some(purpose.into()), + operation: operation.into(), + outcome, + reason: reason.into(), + }; + event.validate()?; + Ok(event) + } + + /// Build a denial whose product privacy contract withholds the requested + /// purpose because no entitlement was resolved. + #[allow(clippy::too_many_arguments)] + pub fn denied_without_purpose( + actor_kind: impl Into, + principal_pseudonym: impl Into, + client_pseudonym: impl Into, + grant_pseudonym: Option, + operation: impl Into, + reason: impl Into, + ) -> Result { + let event = Self { + actor_kind: actor_kind.into(), + principal_pseudonym: principal_pseudonym.into(), + client_pseudonym: client_pseudonym.into(), + grant_pseudonym, + purpose: None, + operation: operation.into(), + outcome: AuthorizationOutcome::Denied, + reason: reason.into(), + }; + event.validate()?; + Ok(event) + } + + #[must_use] + pub fn actor_kind(&self) -> &str { + &self.actor_kind + } + + #[must_use] + pub fn principal_pseudonym(&self) -> &str { + &self.principal_pseudonym + } + + #[must_use] + pub fn client_pseudonym(&self) -> &str { + &self.client_pseudonym + } + + #[must_use] + pub fn grant_pseudonym(&self) -> Option<&str> { + self.grant_pseudonym.as_deref() + } + + #[must_use] + pub fn purpose(&self) -> Option<&str> { + self.purpose.as_deref() + } + + #[must_use] + pub fn operation(&self) -> &str { + &self.operation + } + + #[must_use] + pub const fn outcome(&self) -> AuthorizationOutcome { + self.outcome + } + + #[must_use] + pub fn reason(&self) -> &str { + &self.reason + } + + fn validate(&self) -> Result<(), AuthorizationAuditError> { + if !matches!(self.actor_kind.as_str(), "human" | "agent" | "service") { + return Err(AuthorizationAuditError::InvalidActorKind); + } + if !valid_pseudonym(&self.principal_pseudonym) + || !valid_pseudonym(&self.client_pseudonym) + || self + .grant_pseudonym + .as_deref() + .is_some_and(|value| !valid_pseudonym(value)) + { + return Err(AuthorizationAuditError::InvalidPseudonym); + } + if self + .purpose + .as_deref() + .is_some_and(|value| !valid_code(value)) + || !valid_operation(&self.operation) + || !valid_code(&self.reason) + { + return Err(AuthorizationAuditError::InvalidCode); + } + Ok(()) + } +} + +impl fmt::Debug for AuthorizationAuditEvent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationAuditEvent") + .field("actor_kind", &self.actor_kind) + .field("principal_pseudonym", &"") + .field("client_pseudonym", &"") + .field( + "grant_pseudonym", + &self.grant_pseudonym.as_ref().map(|_| ""), + ) + .field("purpose", &"") + .field("operation", &self.operation) + .field("outcome", &self.outcome) + .field("reason", &self.reason) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[non_exhaustive] +pub enum AuthorizationAuditError { + #[error("authorization audit actor kind is invalid")] + InvalidActorKind, + #[error("authorization audit identity field is not pseudonymized")] + InvalidPseudonym, + #[error("authorization audit code is invalid")] + InvalidCode, +} + +fn valid_code(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= MAX_CODE_BYTES + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} + +fn valid_operation(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9')) + && value.len() <= MAX_CODE_BYTES + && bytes + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +fn valid_pseudonym(value: &str) -> bool { + let Some((prefix, digest)) = value.rsplit_once(':') else { + return false; + }; + let accepted_prefix = matches!(prefix, "hmac-sha256" | "sha256") + || prefix + .strip_prefix("hmac-sha256:v") + .is_some_and(valid_key_version); + accepted_prefix + && digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_key_version(value: &str) -> bool { + !value.is_empty() && !value.starts_with('0') && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn digest(character: char) -> String { + character.to_string().repeat(64) + } + + #[test] + fn event_accepts_platform_and_evidence_pseudonyms() { + let event = AuthorizationAuditEvent::new( + "agent", + format!("hmac-sha256:{}", digest('a')), + format!("hmac-sha256:v2:{}", digest('b')), + Some(format!("hmac-sha256:v2:{}", digest('c'))), + "benefit-review", + "get", + AuthorizationOutcome::Allowed, + "authorization.allowed", + ) + .expect("valid event"); + assert_eq!(event.actor_kind(), "agent"); + assert_eq!(event.outcome(), AuthorizationOutcome::Allowed); + } + + #[test] + fn evidence_operation_identifier_is_preserved() { + let operation = "urn:ulid:01JBY5K5W7XQTB5JX1Q4D0DM6R"; + let event = AuthorizationAuditEvent::new( + "service", + format!("hmac-sha256:v2:{}", digest('a')), + format!("hmac-sha256:v2:{}", digest('b')), + None, + "benefit-review", + operation, + AuthorizationOutcome::Allowed, + "authorization.allowed", + ) + .expect("Evidence operation identifier is a bounded audit operation"); + + assert_eq!(event.operation(), operation); + } + + #[test] + fn raw_identity_values_are_rejected() { + let error = AuthorizationAuditEvent::new( + "human", + "raw-principal-canary", + format!("sha256:{}", digest('b')), + None, + "review", + "get", + AuthorizationOutcome::Denied, + "authorization.client", + ) + .expect_err("raw principal is not accepted"); + assert_eq!(error, AuthorizationAuditError::InvalidPseudonym); + } + + #[test] + fn serialized_shape_has_common_fields_and_omits_absent_grant() { + let event = AuthorizationAuditEvent::new( + "service", + format!("sha256:{}", digest('a')), + format!("sha256:{}", digest('b')), + None, + "registry-operations", + "list", + AuthorizationOutcome::Denied, + "authorization.profile", + ) + .expect("valid event"); + assert_eq!( + serde_json::to_value(event).expect("serializes"), + json!({ + "actorKind":"service", + "principalPseudonym":format!("sha256:{}", digest('a')), + "clientPseudonym":format!("sha256:{}", digest('b')), + "purpose":"registry-operations", + "operation":"list", + "outcome":"denied", + "reason":"authorization.profile" + }) + ); + } + + #[test] + fn denial_can_withhold_unresolved_purpose() { + let event = AuthorizationAuditEvent::denied_without_purpose( + "agent", + format!("sha256:{}", digest('a')), + format!("sha256:{}", digest('b')), + None, + "evaluate", + "authorization.profile", + ) + .expect("valid privacy-minimal denial"); + let value = serde_json::to_value(event).expect("serializes"); + assert!(value.get("purpose").is_none()); + } + + #[test] + fn debug_redacts_pseudonyms_and_purpose() { + let event = AuthorizationAuditEvent::new( + "agent", + format!("hmac-sha256:{}", digest('a')), + format!("hmac-sha256:{}", digest('b')), + Some(format!("hmac-sha256:{}", digest('c'))), + "sensitive-purpose-canary", + "patch", + AuthorizationOutcome::Allowed, + "authorization.allowed", + ) + .expect("valid event"); + let rendered = format!("{event:?}"); + for canary in [ + digest('a'), + digest('b'), + digest('c'), + "sensitive-purpose-canary".to_owned(), + ] { + assert!(!rendered.contains(&canary)); + } + } +} diff --git a/crates/registry-platform-audit/src/lib.rs b/crates/registry-platform-audit/src/lib.rs index 9271bd5764..3cfc8166ab 100644 --- a/crates/registry-platform-audit/src/lib.rs +++ b/crates/registry-platform-audit/src/lib.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 //! Tamper-evident audit envelopes, async sinks, and redaction helpers. +mod authorization; mod persistent_root; + +pub use authorization::{AuthorizationAuditError, AuthorizationAuditEvent, AuthorizationOutcome}; pub use persistent_root::{require_audit_under, PersistentRootFault}; #[cfg(unix)] diff --git a/crates/registry-platform-httputil/README.md b/crates/registry-platform-httputil/README.md index 04be0c71f9..edc13b550e 100644 --- a/crates/registry-platform-httputil/README.md +++ b/crates/registry-platform-httputil/README.md @@ -15,7 +15,11 @@ Outbound HTTP utilities for registry services. limits. - `ServiceBaseUrl`, bearer token providers, and `PrivateKeyJwt` for hardened credential-bearing clients without product semantics. The private-key-JWT - provider preserves its closed `client_credentials` request shape. + provider uses a closed `client_credentials` request shape. Its `exchange` + method accepts a bounded JWT subject assertion and uses RFC 8693 with the + provider's configured resource and scopes. Every exchange is fresh and does + not use or replace the service-token cache. Returned task bounds remain the + consuming resource server's authorization responsibility. - Shared strict response-header bounds and exact-one delta-seconds `Retry-After` parsing. - `ProxyHeaderPolicy` plus request and response header filters for proxy-safe diff --git a/crates/registry-platform-httputil/src/client/mod.rs b/crates/registry-platform-httputil/src/client/mod.rs index bd817fcd28..9366d83db7 100644 --- a/crates/registry-platform-httputil/src/client/mod.rs +++ b/crates/registry-platform-httputil/src/client/mod.rs @@ -16,9 +16,11 @@ pub use outbound::{ transport_protects_the_credential, OutboundOptions, }; pub use private_key_jwt::{ - PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, - DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, - MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, MAXIMUM_TOKEN_RESPONSE_BYTES, + valid_resource_uri, valid_scope_token, PrivateKeyJwt, PrivateKeyJwtConfig, + DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, + MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, + MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, MAXIMUM_SCOPE_PARAMETER_BYTES, + MAXIMUM_TOKEN_RESPONSE_BYTES, }; pub use token::{BearerToken, OAuthErrorCode, StaticToken, TokenError, TokenProvider}; diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs index 96d724d25d..790f36a153 100644 --- a/crates/registry-platform-httputil/src/client/private_key_jwt.rs +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -7,10 +7,13 @@ //! //! It is plain OAuth. Nothing here knows which authorization server it is talking //! to, and the provider carries no claim, route, or vocabulary belonging to any -//! particular issuer. The request body carries only `grant_type`, -//! `client_assertion_type`, and `client_assertion`; a server that also requires a -//! scope, a resource indicator, or a body `client_id` on this grant must use a -//! custom [`TokenProvider`]. +//! particular issuer. The request body carries `grant_type`, `client_id`, +//! `client_assertion_type`, and `client_assertion`, plus the optional +//! RFC 8707 `resource` indicator and RFC 6749 `scope` string of +//! [`PrivateKeyJwtConfig`]. The resource and scopes are fixed configuration of +//! the provider, not per-request arguments: one provider instance holds exactly +//! one client, resource, and scope set, so its cache can never hand one +//! configuration's credential to another. //! //! The assertion itself is built by //! [`registry_platform_authcommon::client_assertion`]. Nothing else in the @@ -94,6 +97,9 @@ const _: () = assert!(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS == 86_400); /// The grant this provider asks for. The client authenticates as itself, on its /// own behalf, which is the closed grant this provider supports. const GRANT_TYPE: &str = "client_credentials"; +const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT_SUBJECT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt"; +const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; /// The client authentication method of RFC 7523 section 2.2. const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; @@ -112,6 +118,99 @@ const BEARER_TOKEN_TYPE: &str = "bearer"; /// that could only produce a response this provider would refuse. pub const MAXIMUM_TOKEN_RESPONSE_BYTES: u64 = 16 * 1024; +/// Longest individual scope this provider will request, and the most scopes it +/// will request at once. +/// +/// The per-token bound is the one the stack's client registries already hold +/// (a 1..=256 byte RFC 6749 scope-token); the count bound keeps one +/// configuration's canonical `scope` value inside the form-encoding budget +/// below on its own. +pub const MAXIMUM_REQUESTED_SCOPE_BYTES: usize = 256; +pub const MAXIMUM_REQUESTED_SCOPES: usize = 32; + +/// Longest canonical `scope` value this provider will send, and longest +/// response `scope` value it will read. +/// +/// `MAXIMUM_REQUESTED_SCOPES` tokens of `MAXIMUM_REQUESTED_SCOPE_BYTES` bytes +/// plus separators bound the sent value at 8_288 bytes; the tighter budget +/// here keeps the encoded form parameter inside ordinary transport limits +/// while leaving room for a server that answers with the granted superset. +pub const MAXIMUM_SCOPE_PARAMETER_BYTES: usize = 4 * 1024; + +/// Longest RFC 8707 resource indicator this provider will send. +const MAXIMUM_RESOURCE_URI_BYTES: usize = 4 * 1024; + +/// Validate the caller's original URI bytes before a parser can trim or encode +/// them into a different identifier. Percent escapes must be complete. +fn valid_resource_uri_bytes(resource: &str) -> bool { + let bytes = resource.as_bytes(); + if bytes.is_empty() || bytes.len() > MAXIMUM_RESOURCE_URI_BYTES { + return false; + } + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + return false; + } + index += 3; + continue; + } + if !byte.is_ascii_alphanumeric() && !b"-._~:/?[]@!$&'()*+,;=".contains(&byte) { + return false; + } + index += 1; + } + true +} + +/// Whether `resource` is one bounded RFC 8707 absolute URI, represented in +/// exact ASCII URI bytes with no fragment or userinfo. +/// +/// This checks the original string before URL parsing can trim whitespace or +/// percent-encode raw Unicode. It is suitable for configuration preflight as +/// well as [`PrivateKeyJwt`] construction. +pub fn valid_resource_uri(resource: &str) -> bool { + if !valid_resource_uri_bytes(resource) { + return false; + } + Url::parse(resource).is_ok_and(|parsed| { + !parsed.scheme().is_empty() + && parsed.fragment().is_none() + && parsed.username().is_empty() + && parsed.password().is_none() + }) +} + +/// One RFC 6749 scope-token: at least one byte, and only the bytes the +/// grammar's `scope-token` production allows. +pub fn valid_scope_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte == 0x21 || (0x23..=0x5b).contains(&byte) || (0x5d..=0x7e).contains(&byte) + }) +} + +/// Split a scope string as RFC 6749 defines it: one or more scope-tokens +/// separated by exactly one space each. An empty string, a leading or trailing +/// space, a doubled space, or a byte outside the `scope-token` set is a value +/// no conformance-checking server would have sent or accepted, so it is +/// refused rather than guessed at. +fn split_scope_string(value: &str) -> Result, ()> { + if value.is_empty() || value.len() > MAXIMUM_SCOPE_PARAMETER_BYTES { + return Err(()); + } + let tokens: Vec<&str> = value.split(' ').collect(); + if tokens.iter().any(|token| !valid_scope_token(token)) { + return Err(()); + } + Ok(tokens) +} + /// The two readings the provider reasons about. /// /// They are separate because they answer different questions. An assertion claim @@ -146,6 +245,8 @@ pub struct PrivateKeyJwtConfig { client_id: String, client_key: PrivateJwk, audience: Option, + resource: Option, + scopes: Option>, assertion_lifetime_seconds: i64, refresh_margin_seconds: i64, request_timeout: Duration, @@ -171,6 +272,8 @@ impl PrivateKeyJwtConfig { client_id: client_id.into(), client_key, audience: None, + resource: None, + scopes: None, assertion_lifetime_seconds: DEFAULT_ASSERTION_LIFETIME_SECONDS, refresh_margin_seconds: DEFAULT_REFRESH_MARGIN_SECONDS, request_timeout: DEFAULT_REQUEST_TIMEOUT, @@ -196,6 +299,43 @@ impl PrivateKeyJwtConfig { self } + /// State the RFC 8707 resource indicator the token is requested for. + /// + /// The value is the resource server's registered identifier — what the + /// issued access token's audience must name back — not a URL the client + /// fetches. It must be one absolute URI carrying no fragment and no + /// userinfo; a value that breaks that rule is refused when the provider is + /// built rather than here. + /// + /// The resource is fixed configuration of this provider. A deployment that + /// must address a second resource server configures a second provider, so + /// neither provider's cache can substitute the other's credential. + #[must_use] + pub fn with_resource(mut self, resource: impl Into) -> Self { + self.resource = Some(resource.into()); + self + } + + /// State the scopes the token is requested for, in the order requested. + /// + /// Each value must be a nonempty RFC 6749 scope-token of at most + /// [`MAXIMUM_REQUESTED_SCOPE_BYTES`] bytes, no two may repeat, at most + /// [`MAXIMUM_REQUESTED_SCOPES`] may be stated, and their canonical + /// space-delimited encoding must stay within + /// [`MAXIMUM_SCOPE_PARAMETER_BYTES`]; a set that breaks any of those is + /// refused when the provider is built rather than here. A requested scope + /// may narrow the client's registered permission set; it can never widen + /// it, and this provider does not ask the server to. + /// + /// When scopes are configured and the token response states a `scope`, the + /// provider requires every configured scope to be present in it before the + /// credential is used or cached. + #[must_use] + pub fn with_scopes(mut self, scopes: impl IntoIterator>) -> Self { + self.scopes = Some(scopes.into_iter().map(Into::into).collect()); + self + } + /// Must be within `1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS`; an out-of-range /// value is refused when the provider is built rather than here. #[must_use] @@ -266,6 +406,8 @@ impl fmt::Debug for PrivateKeyJwtConfig { ) .field("client_id", &self.client_id) .field("audience", &self.audience) + .field("resource", &self.resource) + .field("scopes", &self.scopes) .field( "assertion_lifetime_seconds", &self.assertion_lifetime_seconds, @@ -293,6 +435,12 @@ pub struct PrivateKeyJwt { client_id: String, audience: String, audience_is_token_endpoint: bool, + resource: Option, + /// The configured scopes in their canonical space-delimited encoding, + /// parsed once when the provider is built. + scope: Option, + /// The configured scopes as a set, for the response-scope check. + requested_scopes: Vec, assertion_lifetime_seconds: i64, refresh_margin_seconds: i64, client_key: PrivateJwk, @@ -311,6 +459,36 @@ pub struct PrivateKeyJwt { } impl PrivateKeyJwt { + /// Exchange one externally signed JWT assertion for an access token. + /// + /// The resource and scopes are fixed when this provider is constructed. + /// Each call authenticates with a fresh client assertion and performs its + /// own request. Task credentials never read or replace the service-token + /// cache, including when two tasks use the same client concurrently. + /// The consuming resource server must verify the returned token's bounds. + pub async fn exchange(&self, subject_token: &str) -> Result { + if self.resource.is_none() || self.scope.is_none() { + return Err(TokenError::Configuration { + reason: "token exchange requires a configured resource and scopes", + }); + } + if subject_token.is_empty() + || subject_token.len() > 32 * 1024 + || !subject_token.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(TokenError::Invalid { + reason: "the subject assertion must be bounded non-empty visible ASCII", + }); + } + self.acquire_for_grant( + self.clock.unix_seconds(), + self.clock.monotonic(), + Some(subject_token), + ) + .await + .map(|acquired| acquired.token) + } + /// Refuse a configuration that cannot authenticate, cannot protect its /// assertion in transit, or cannot produce an assertion the server it /// registered with can verify. @@ -417,6 +595,47 @@ impl PrivateKeyJwt { if config.request_timeout.is_zero() || config.connect_timeout.is_zero() { return Err(refuse("the timeouts must be greater than zero")); } + // The resource indicator is an identifier, not a URL this client + // fetches, so it is validated as RFC 8707 section 2.1 defines the + // parameter: one absolute URI carrying no fragment. Userinfo has no + // meaning in an identifier and would smuggle a credential into the + // form body, so it is refused outright. + if let Some(resource) = &config.resource { + let invalid = + || refuse("the resource must be one absolute URI without a fragment or userinfo"); + if !valid_resource_uri(resource) { + return Err(invalid()); + } + } + // Scope grammar and bounds are checked here rather than per request, + // for the same reason as every other refusal above: a set the provider + // would refuse to send belongs in one construction-time failure. + let (scope, requested_scopes) = if let Some(scopes) = &config.scopes { + if scopes.is_empty() { + return Err(refuse("the requested scopes must state at least one scope")); + } + if scopes.len() > MAXIMUM_REQUESTED_SCOPES { + return Err(refuse("the requested scopes must be at most 32 values")); + } + let mut seen = std::collections::BTreeSet::new(); + for value in scopes { + if value.len() > MAXIMUM_REQUESTED_SCOPE_BYTES || !valid_scope_token(value) { + return Err(refuse( + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + )); + } + if !seen.insert(value.as_str()) { + return Err(refuse("the requested scopes must not repeat a value")); + } + } + let canonical = scopes.join(" "); + if canonical.len() > MAXIMUM_SCOPE_PARAMETER_BYTES { + return Err(refuse("the requested scopes must encode within 4096 bytes")); + } + (Some(canonical), scopes.clone()) + } else { + (None, Vec::new()) + }; let http = outbound::build_client(OutboundOptions { request_timeout: config.request_timeout, @@ -436,6 +655,9 @@ impl PrivateKeyJwt { .audience .unwrap_or_else(|| config.token_endpoint.as_str().to_owned()), audience_is_token_endpoint, + resource: config.resource, + scope, + requested_scopes, token_endpoint: config.token_endpoint, client_id: config.client_id, assertion_lifetime_seconds: config.assertion_lifetime_seconds, @@ -489,17 +711,52 @@ impl PrivateKeyJwt { /// `monotonic_now` is what the cache deadline of whatever it issues is /// measured from. async fn acquire(&self, now: i64, monotonic_now: Instant) -> Result { + self.acquire_for_grant(now, monotonic_now, None).await + } + + async fn acquire_for_grant( + &self, + now: i64, + monotonic_now: Instant, + subject_token: Option<&str>, + ) -> Result { let assertion = self.sign_assertion(now)?; // The assertion is a credential, so it lives in a scrubbed buffer here. // The body reqwest owns afterwards cannot be wiped, which is why the // assertion is single use and its lifetime is bounded. - let body = Zeroizing::new( - url::form_urlencoded::Serializer::new(String::new()) - .append_pair("grant_type", GRANT_TYPE) - .append_pair("client_assertion_type", CLIENT_ASSERTION_TYPE) - .append_pair("client_assertion", &assertion) - .finish(), - ); + // + // The body `client_id` repeats the identity the assertion proves, which + // RFC 6749 section 3.2.1 permits for this client-authentication method + // and servers that route on it require. The resource and scope + // parameters appear exactly once each, and only when configured. The + // serializer is scoped to this block so it is dropped before the + // exchange below, keeping the future this runs in `Send`. + let body = Zeroizing::new({ + let mut form = url::form_urlencoded::Serializer::new(String::new()); + form.append_pair( + "grant_type", + if subject_token.is_some() { + TOKEN_EXCHANGE_GRANT_TYPE + } else { + GRANT_TYPE + }, + ); + form.append_pair("client_id", &self.client_id); + form.append_pair("client_assertion_type", CLIENT_ASSERTION_TYPE); + form.append_pair("client_assertion", &assertion); + if let Some(subject_token) = subject_token { + form.append_pair("subject_token", subject_token); + form.append_pair("subject_token_type", JWT_SUBJECT_TOKEN_TYPE); + form.append_pair("requested_token_type", ACCESS_TOKEN_TYPE); + } + if let Some(scope) = &self.scope { + form.append_pair("scope", scope); + } + if let Some(resource) = &self.resource { + form.append_pair("resource", resource); + } + form.finish() + }); let request = if let Some(policy) = &self.fetch_url_policy { let validated = policy @@ -567,6 +824,27 @@ impl PrivateKeyJwt { if !issued.token_type.eq_ignore_ascii_case(BEARER_TOKEN_TYPE) { return Err(TokenError::Protocol { status }); } + if subject_token.is_some() && issued.issued_token_type.as_deref() != Some(ACCESS_TOKEN_TYPE) + { + return Err(TokenError::Protocol { status }); + } + // A stated response scope is a claim about what the credential may do, + // so it is held to the same grammar as the request scope and checked + // before the credential is used or cached. An absent scope keeps + // RFC 6749 section 5.1's meaning: unchanged from what was requested, + // and the consumers of this credential still enforce their own scopes. + if let Some(granted) = &issued.scope { + let granted_tokens = + split_scope_string(granted).map_err(|_| TokenError::Protocol { status })?; + if !self.requested_scopes.is_empty() + && self + .requested_scopes + .iter() + .any(|scope| !granted_tokens.contains(&scope.as_str())) + { + return Err(TokenError::ScopeNarrowed); + } + } Ok(AcquiredToken { // Moved rather than copied, so the credential ends up in the buffer // `BearerToken` wipes on drop. @@ -661,6 +939,8 @@ impl fmt::Debug for PrivateKeyJwt { std::borrow::Cow::Borrowed(self.audience.as_str()) }, ) + .field("resource", &self.resource) + .field("scopes", &self.requested_scopes.as_slice()) .field( "assertion_lifetime_seconds", &self.assertion_lifetime_seconds, @@ -683,6 +963,8 @@ struct IssuedToken { access_token: String, token_type: String, expires_in: Option, + scope: Option, + issued_token_type: Option, } /// The error response of RFC 6749 section 5.2. @@ -983,6 +1265,12 @@ mod tests { /// The token response a compliant authorization server returns. fn issued(expires_in: Option) -> ResponseTemplate { + issued_with_scope(expires_in, None) + } + + /// The token response a compliant authorization server returns, with the + /// optional `scope` member of RFC 6749 section 5.1 stated. + fn issued_with_scope(expires_in: Option, scope: Option<&str>) -> ResponseTemplate { let mut body = json!({ "access_token": ISSUED_CREDENTIAL, "token_type": "Bearer", @@ -990,6 +1278,9 @@ mod tests { if let Some(expires_in) = expires_in { body["expires_in"] = json!(expires_in); } + if let Some(scope) = scope { + body["scope"] = json!(scope); + } ResponseTemplate::new(200).set_body_json(body) } @@ -1267,6 +1558,9 @@ mod tests { .and(header("content-type", "application/x-www-form-urlencoded")) .and(header("accept", "application/json")) .and(body_string_contains("grant_type=client_credentials")) + .and(body_string_contains( + "client_id=urn%3Aexample%3Aclient%3Arelying-party", + )) .and(body_string_contains( "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", )) @@ -1286,6 +1580,411 @@ mod tests { assert_eq!(token_requests(&server).await, 1); } + /// The body of the one request a provider makes, decoded from form + /// encoding, so a test can count parameters rather than substring-match a + /// body it cannot parse. + async fn request_form_body(server: &MockServer) -> Vec<(String, String)> { + let requests = server + .received_requests() + .await + .expect("the mock server records its requests"); + let body = String::from_utf8(requests[0].body.clone()) + .expect("the token request body is form-encoded ASCII"); + url::form_urlencoded::parse(body.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() + } + + #[tokio::test] + async fn task_exchanges_do_not_share_or_replace_the_service_token_cache() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(body_string_contains("grant_type=client_credentials")) + .respond_with(issued(Some(TOKEN_LIFETIME_SECONDS))) + .expect(1) + .mount(&server) + .await; + for (subject, credential) in [("grant-one", "task-one"), ("grant-two", "task-two")] { + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(body_string_contains(format!("subject_token={subject}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": credential, + "token_type": "Bearer", + "issued_token_type": ACCESS_TOKEN_TYPE, + "expires_in": 300, + "scope": "records:read" + }))) + .expect(1) + .mount(&server) + .await; + } + let clock = Arc::new(TestClock::new(NOW)); + let configured = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:records") + .with_scopes(["records:read"]), + clock, + ) + .unwrap(); + assert_eq!( + configured.bearer_token().await.unwrap().expose(), + ISSUED_CREDENTIAL + ); + let (first, second) = tokio::join!( + configured.exchange("grant-one"), + configured.exchange("grant-two") + ); + assert_eq!(first.unwrap().expose(), "task-one"); + assert_eq!(second.unwrap().expose(), "task-two"); + assert_eq!( + configured.bearer_token().await.unwrap().expose(), + ISSUED_CREDENTIAL + ); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 3); + let mut assertion_ids = std::collections::BTreeSet::new(); + for request in requests { + let form: std::collections::BTreeMap<_, _> = url::form_urlencoded::parse(&request.body) + .into_owned() + .collect(); + let (_, claims, _) = parts(&form["client_assertion"]); + assert!(assertion_ids.insert(claims["jti"].as_str().unwrap().to_owned())); + assert!(!form.contains_key("client_secret")); + assert!(!form.contains_key("actor_token")); + if form.contains_key("subject_token") { + assert_eq!(form["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE); + assert_eq!(form["subject_token_type"], JWT_SUBJECT_TOKEN_TYPE); + assert_eq!(form["requested_token_type"], ACCESS_TOKEN_TYPE); + assert_eq!(form["resource"], "urn:registry:records"); + assert_eq!(form["scope"], "records:read"); + } + } + } + + #[tokio::test] + async fn exchange_requires_explicit_destination_and_bounded_subject_before_io() { + let server = MockServer::start().await; + let clock = Arc::new(TestClock::new(NOW)); + let ordinary = provider(endpoint(&server.uri()), &clock); + assert!(matches!( + ordinary.exchange("grant").await, + Err(TokenError::Configuration { .. }) + )); + let configured = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:records") + .with_scopes(["records:read"]), + clock, + ) + .unwrap(); + for invalid in ["", "subject\r\ncanary", &"a".repeat(32 * 1024 + 1)] { + let error = configured.exchange(invalid).await.unwrap_err(); + assert!(matches!(error, TokenError::Invalid { .. })); + assert!(!error.to_string().contains("canary")); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn exchange_rejects_missing_or_wrong_issued_type_and_narrowed_scope() { + for (issued_type, scope) in [ + (None, "records:read"), + (Some(JWT_SUBJECT_TOKEN_TYPE), "records:read"), + (Some(ACCESS_TOKEN_TYPE), "other:read"), + ] { + let mut body = json!({ + "access_token": "synthetic-credential-canary", + "token_type": "Bearer", "expires_in": 300, "scope": scope + }); + if let Some(kind) = issued_type { + body["issued_token_type"] = json!(kind); + } + let server = + token_endpoint_serving(ResponseTemplate::new(200).set_body_json(body)).await; + let configured = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:records") + .with_scopes(["records:read"]), + Arc::new(TestClock::new(NOW)), + ) + .unwrap(); + let error = configured.exchange("grant").await.unwrap_err(); + assert!(!error.to_string().contains("canary")); + if issued_type == Some(ACCESS_TOKEN_TYPE) { + assert_eq!(error, TokenError::ScopeNarrowed); + } else { + assert!(matches!(error, TokenError::Protocol { status: 200 })); + } + } + } + + /// A configured resource and scope set are sent once each, the scope as one + /// canonical space-delimited parameter, and nothing of either when no + /// configuration states them. This is the request half of RFC 8707 and + /// RFC 6749 section 3.3 as the ThunderID-style issuers read them. + #[tokio::test] + async fn a_configured_resource_and_scopes_are_sent_once_each() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let configured = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:evidence") + .with_scopes(["evidence:invoke", "records:read"]), + clock.clone(), + ) + .expect("the provider is usable as configured"); + + configured.bearer_token().await.expect("a credential"); + let body = request_form_body(&server).await; + let count = |name: &str| body.iter().filter(|(key, _)| key == name).count(); + assert_eq!(count("resource"), 1, "the resource parameter is not unique"); + assert_eq!(count("scope"), 1, "the scope parameter is not unique"); + assert_eq!( + body.iter() + .find(|(key, _)| key == "resource") + .expect("the resource is present") + .1, + "urn:registry:evidence" + ); + assert_eq!( + body.iter() + .find(|(key, _)| key == "scope") + .expect("the scope is present") + .1, + "evidence:invoke records:read", + "the scopes are one canonical space-delimited parameter" + ); + + // Unconfigured: the parameters are absent, not empty. + let plain_server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let unconfigured = provider(endpoint(&plain_server.uri()), &clock); + unconfigured.bearer_token().await.expect("a credential"); + let body = request_form_body(&plain_server).await; + assert!(!body.iter().any(|(key, _)| key == "resource")); + assert!(!body.iter().any(|(key, _)| key == "scope")); + } + + /// The assertion audience and the resource indicator answer different + /// questions — who checks the client's authentication versus which + /// resource server the credential is for — so a provider configured with + /// both must not let one stand in for the other, and an issuer that + /// expects the issuer identifier as the assertion audience keeps that + /// default behavior with a resource configured. + #[tokio::test] + async fn the_assertion_audience_is_never_the_resource_indicator() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_audience("https://issuer.example.org") + .with_resource("urn:registry:evidence") + .with_scopes(["evidence:invoke"]), + clock.clone(), + ) + .expect("the provider is usable as configured"); + + let assertion = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (_, claims, _) = parts(&assertion); + assert_eq!(claims["aud"], json!("https://issuer.example.org")); + assert_ne!( + claims["aud"], + json!("urn:registry:evidence"), + "the resource indicator was substituted for the assertion audience" + ); + provider.bearer_token().await.expect("a credential"); + let body = request_form_body(&server).await; + assert_eq!( + body.iter() + .find(|(key, _)| key == "resource") + .expect("the resource is present") + .1, + "urn:registry:evidence" + ); + + // With no audience override, the assertion keeps its token-endpoint + // default even while a resource is configured. + let default_audience = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:evidence"), + clock, + ) + .expect("the provider is usable as configured"); + let assertion = default_audience + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (_, claims, _) = parts(&assertion); + assert_eq!( + claims["aud"], + json!(format!("{}{TOKEN_PATH}", server.uri())) + ); + } + + /// Two providers built from two configurations never hand each other a + /// credential: the cache belongs to one immutable configuration, and the + /// second provider's request carries its own resource and scopes. This is + /// the configuration-isolation half of the scope and resource contract. + #[tokio::test] + async fn providers_do_not_share_credentials_across_configurations() { + let read_server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let write_server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let reader = PrivateKeyJwt::with_clock( + config(endpoint(&read_server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:evidence") + .with_scopes(["evidence:invoke"]), + clock.clone(), + ) + .expect("the provider is usable as configured"); + let writer = PrivateKeyJwt::with_clock( + config(endpoint(&write_server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:other") + .with_scopes(["evidence:invoke", "records:write"]), + clock, + ) + .expect("the provider is usable as configured"); + + let first = reader + .bearer_token() + .await + .expect("the reader acquires a credential"); + let second = writer + .bearer_token() + .await + .expect("the writer acquires its own credential"); + assert_eq!(first.expose(), ISSUED_CREDENTIAL); + assert_eq!(second.expose(), ISSUED_CREDENTIAL); + // Each provider made exactly its own request; neither reused the other. + assert_eq!(token_requests(&read_server).await, 1); + assert_eq!(token_requests(&write_server).await, 1); + // A reuse of the reader is served from its own cache, still alone. + reader.bearer_token().await.expect("the cached credential"); + assert_eq!(token_requests(&read_server).await, 1); + let writer_body = request_form_body(&write_server).await; + assert_eq!( + writer_body + .iter() + .find(|(key, _)| key == "scope") + .expect("the writer states its scopes") + .1, + "evidence:invoke records:write" + ); + } + + /// A server that states a `scope` missing a requested scope has issued a + /// credential the deployment cannot use where it meant to. The provider + /// refuses it before use and caches nothing, so the next caller asks the + /// server again rather than replaying the narrowed credential. + #[tokio::test] + async fn a_response_scope_missing_a_requested_scope_is_refused_and_not_cached() { + let server = token_endpoint_serving(issued_with_scope( + Some(TOKEN_LIFETIME_SECONDS), + Some("records:read"), + )) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_scopes(["records:read", "records:write"]), + clock, + ) + .expect("the provider is usable as configured"); + + assert_eq!( + provider + .bearer_token() + .await + .expect_err("the scope was narrowed"), + TokenError::ScopeNarrowed + ); + // Nothing was cached: the next acquisition reaches the server again. + assert_eq!( + provider + .bearer_token() + .await + .expect_err("the narrowed credential was cached"), + TokenError::ScopeNarrowed + ); + assert_eq!( + token_requests(&server).await, + 2, + "a refused credential was replayed from the cache" + ); + } + + /// A stated response scope that covers the request, or one that grants + /// more than was requested, is accepted and cached; an absent scope keeps + /// RFC 6749's unchanged-scope meaning and is accepted for the same + /// lifetime. + #[tokio::test] + async fn a_covering_or_absent_response_scope_is_accepted_and_cached() { + for granted in [ + Some("records:read records:write"), + Some("records:read records:write admin"), + None, + ] { + let server = + token_endpoint_serving(issued_with_scope(Some(TOKEN_LIFETIME_SECONDS), granted)) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_scopes(["records:read", "records:write"]), + clock, + ) + .expect("the provider is usable as configured"); + + provider.bearer_token().await.expect("a credential"); + provider + .bearer_token() + .await + .expect("the cached credential"); + assert_eq!( + token_requests(&server).await, + 1, + "the credential for granted scope {granted:?} was not cached" + ); + } + } + + /// A malformed stated scope — empty, doubled separators, or bytes outside + /// the scope-token set — is a response this provider cannot reason about, + /// so it is refused as a protocol failure and cached nothing. + #[tokio::test] + async fn a_malformed_response_scope_is_a_protocol_failure() { + for malformed in [ + "", + "records:read records:write", + "records:read ", + "re\u{00e9}cords", + ] { + let server = token_endpoint_serving(issued_with_scope( + Some(TOKEN_LIFETIME_SECONDS), + Some(malformed), + )) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_scopes(["records:read"]), + clock, + ) + .expect("the provider is usable as configured"); + + assert_eq!( + provider + .bearer_token() + .await + .expect_err("a malformed scope string is not a usable response"), + TokenError::Protocol { status: 200 }, + "malformed scope {malformed:?}" + ); + } + } + /// A credential is reused while it has more life left than the refresh margin, /// and a caller arriving inside the margin gets a fresh one instead of a /// credential that may expire in flight. @@ -1847,6 +2546,86 @@ mod tests { b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), ), ), + ( + "the resource must be one absolute URI without a fragment or userinfo", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource("registry:evidence#fragment"), + ), + ( + "the resource must be one absolute URI without a fragment or userinfo", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource("https://client:canary@registry.example.org/"), + ), + ( + "the resource must be one absolute URI without a fragment or userinfo", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource("/relative/path"), + ), + ( + "the resource must be one absolute URI without a fragment or userinfo", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource(""), + ), + ( + "the requested scopes must state at least one scope", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes(Vec::::new()), + ), + ( + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes(["records:read", ""]), + ), + ( + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes(["records:read", "re\u{00e9}cords"]), + ), + ( + "the requested scopes must be 1..=256 byte RFC 6749 scope-tokens", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes(["a".repeat(MAXIMUM_REQUESTED_SCOPE_BYTES + 1)]), + ), + ( + "the requested scopes must not repeat a value", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes(["records:read", "records:read"]), + ), + ( + "the requested scopes must be at most 32 values", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_scopes((0..=MAXIMUM_REQUESTED_SCOPES).map(|index| format!("scope:{index}"))), + ), ]; for (reason, candidate) in cases { @@ -1915,4 +2694,51 @@ mod tests { assert!(!rendered.contains("canary"), "{rendered}"); assert!(rendered.contains("issuer.example.org/token"), "{rendered}"); } + #[test] + fn resource_indicator_requires_exact_ascii_uri_bytes() { + for resource in [ + " urn:registry:evidence", + "urn:registry:evidence ", + "urn:registry:evidence\n", + "urn:registry:café", + "https://registry.example.org/{record}", + "urn:registry:evidence%GG", + "urn:registry:evidence%2", + ] { + assert!(!valid_resource_uri(resource)); + let error = PrivateKeyJwt::new( + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource(resource), + ) + .expect_err("the raw resource bytes are not an RFC 3986 URI"); + assert_eq!( + error, + TokenError::Configuration { + reason: "the resource must be one absolute URI without a fragment or userinfo" + } + ); + } + let overlong = format!("urn:registry:{}", "a".repeat(MAXIMUM_RESOURCE_URI_BYTES)); + assert!(!valid_resource_uri(&overlong)); + assert!(PrivateKeyJwt::new( + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)) + ) + .with_resource(overlong) + ) + .is_err()); + assert!(valid_resource_uri("urn:registry:evidence%20record")); + PrivateKeyJwt::new( + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_resource("urn:registry:evidence%20record"), + ) + .expect("a complete ASCII percent escape is accepted"); + } } diff --git a/crates/registry-platform-httputil/src/client/token.rs b/crates/registry-platform-httputil/src/client/token.rs index 271a6bf99c..0dacfb2853 100644 --- a/crates/registry-platform-httputil/src/client/token.rs +++ b/crates/registry-platform-httputil/src/client/token.rs @@ -136,6 +136,13 @@ pub enum TokenError { #[error("the authorization server declined to issue a token: {code}")] Refused { code: OAuthErrorCode }, + /// The token response stated a scope that does not include every scope + /// this provider was configured to request. The credential is neither used + /// nor cached: presenting it would let the deployment's first protected + /// request fail in place of its token acquisition. + #[error("the token response does not include every requested scope")] + ScopeNarrowed, + /// The answer was not a token response this crate can use: an unexpected /// status, an unexpected media type, an unreadable body, or a token type the /// service request cannot present. @@ -160,6 +167,7 @@ impl TokenError { Self::Configuration { .. } => "configuration", Self::Transport { .. } => "transport", Self::Refused { .. } => "refused", + Self::ScopeNarrowed => "scope_narrowed", Self::Protocol { .. } => "protocol", } } @@ -290,6 +298,10 @@ mod tests { }, "the authorization server declined to issue a token: unregistered_error_code", ), + ( + TokenError::ScopeNarrowed, + "the token response does not include every requested scope", + ), ( TokenError::Protocol { status: 500 }, "the token response does not satisfy the OAuth 2.0 contract: status 500", @@ -333,10 +345,11 @@ mod tests { ), ( TokenError::Refused { - code: OAuthErrorCode::InvalidClient, + code: OAuthErrorCode::InvalidScope, }, "refused", ), + (TokenError::ScopeNarrowed, "scope_narrowed"), (TokenError::Protocol { status: 500 }, "protocol"), ]; for (error, kind) in &cases { diff --git a/crates/registry-platform-httputil/src/lib.rs b/crates/registry-platform-httputil/src/lib.rs index 29e28d1cdf..7c6c7ad912 100644 --- a/crates/registry-platform-httputil/src/lib.rs +++ b/crates/registry-platform-httputil/src/lib.rs @@ -11,11 +11,12 @@ pub mod client; pub mod destination; pub use client::{ - BearerToken, OAuthErrorCode, PrivateKeyJwt, PrivateKeyJwtConfig, ServiceBaseUrl, - ServiceBaseUrlError, ServiceBaseUrlJoinError, StaticToken, TokenError, TokenProvider, - TransportKind, DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, - MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, - MAXIMUM_TOKEN_RESPONSE_BYTES, + valid_resource_uri, valid_scope_token, BearerToken, OAuthErrorCode, PrivateKeyJwt, + PrivateKeyJwtConfig, ServiceBaseUrl, ServiceBaseUrlError, ServiceBaseUrlJoinError, StaticToken, + TokenError, TokenProvider, TransportKind, DEFAULT_ASSERTION_LIFETIME_SECONDS, + DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, + MAXIMUM_SCOPE_PARAMETER_BYTES, MAXIMUM_TOKEN_RESPONSE_BYTES, }; /// Maximum number of response header field lines accepted by shared transports. diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index 79a638fbde..aca3ab6bcc 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -11,6 +11,8 @@ OIDC discovery, JWKS caching, and JWT verification for registry services. - JWT verification with issuer, audience, algorithm, `typ`, `kid`, time, client, and scope handling. - Scope mapping for translating provider scopes into platform permissions. +- Strict extraction and client/resource binding for the shared contextual + authorization claims. ## Typical Use @@ -69,6 +71,10 @@ async fn build_verifier() -> Result> { provider key rotations are not blocked for the full negative-cache TTL. - If `allowed_clients` is set, `azp` takes precedence over `client_id`; `sub` is never used as a client identity. +- Task-grant parsing is optional until any configured core grant claim is + present. Once present, every core member and both token and grant deadlines + are required. Product runtimes still own trusted-authority mappings and the + supported operation vocabulary. - Store replay state, authorization decisions, and tenant boundaries in the consuming service. diff --git a/crates/registry-platform-oidc/src/authorization_claims.rs b/crates/registry-platform-oidc/src/authorization_claims.rs new file mode 100644 index 0000000000..fa800e68b7 --- /dev/null +++ b/crates/registry-platform-oidc/src/authorization_claims.rs @@ -0,0 +1,871 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Strict extraction of the shared contextual-authorization claims. + +use std::collections::HashSet; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::{Claims, VerifiedToken}; + +const MAX_CLAIM_NAME_BYTES: usize = 128; +const MAX_CLAIM_VALUE_BYTES: usize = 512; +const MAX_PURPOSE_BYTES: usize = 128; +const MAX_BREG_PERMISSIONS: usize = 64; +const MAX_BREG_OPERATIONS: usize = 32; + +const REGISTERED_OR_AUTHENTICATION_CLAIMS: &[&str] = &[ + "iss", + "sub", + "aud", + "exp", + "iat", + "nbf", + "jti", + "azp", + "client_id", + "scope", + "cnf", + "act", +]; + +/// Names of the direct, top-level claims in the contextual-authorization profile. +/// +/// A dot is a valid character in a claim name and remains literal. This keeps +/// the direct-claim behavior of existing Registry Stack resource servers. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ClaimNames { + pub actor_kind: String, + pub purpose: String, + pub grant_id: String, + pub grant_authority: String, + pub grant_source_issuer: String, + pub grant_client: String, + pub grant_resource: String, + pub grant_exp: String, + pub grant_bounds: String, + pub approver: String, +} + +impl Default for ClaimNames { + fn default() -> Self { + Self { + actor_kind: "registry_actor_kind".to_owned(), + purpose: "registry_purpose".to_owned(), + grant_id: "registry_grant_id".to_owned(), + grant_authority: "registry_grant_authority".to_owned(), + grant_source_issuer: "registry_grant_source_issuer".to_owned(), + grant_client: "registry_grant_client".to_owned(), + grant_resource: "registry_grant_resource".to_owned(), + grant_exp: "registry_grant_exp".to_owned(), + grant_bounds: "registry_grant_bounds".to_owned(), + approver: "registry_approver".to_owned(), + } + } +} + +impl ClaimNames { + /// Validate claim-name syntax and prevent one claim from serving two roles. + pub fn validate(&self) -> Result<(), ClaimError> { + let names = [ + &self.actor_kind, + &self.purpose, + &self.grant_id, + &self.grant_authority, + &self.grant_source_issuer, + &self.grant_client, + &self.grant_resource, + &self.grant_exp, + &self.grant_bounds, + &self.approver, + ]; + if names.iter().any(|name| !valid_claim_name(name)) + || names + .iter() + .any(|name| REGISTERED_OR_AUTHENTICATION_CLAIMS.contains(&name.as_str())) + || names.iter().collect::>().len() != names.len() + { + return Err(ClaimError::InvalidNames); + } + Ok(()) + } + + fn core_grant_names(&self) -> [&str; 7] { + [ + &self.grant_id, + &self.grant_authority, + &self.grant_source_issuer, + &self.grant_client, + &self.grant_resource, + &self.grant_exp, + &self.grant_bounds, + ] + } +} + +/// The kind of actor executing the authenticated operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + Human, + Agent, + Service, +} + +impl ActorKind { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Human => "human", + Self::Agent => "agent", + Self::Service => "service", + } + } +} + +/// A bounded BREG permission carried inside a signed grant. +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BregPermission { + collection: String, + operations: Vec, +} + +impl BregPermission { + #[must_use] + pub fn collection(&self) -> &str { + &self.collection + } + + #[must_use] + pub fn operations(&self) -> &[String] { + &self.operations + } +} + +impl fmt::Debug for BregPermission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BregPermission") + .field("collection", &"") + .field("operation_count", &self.operations.len()) + .finish() + } +} + +/// Product-specific bounds carried by a task grant. +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum GrantBounds { + Evidence { requirement: String }, + Breg { permissions: Vec }, +} + +impl GrantBounds { + #[must_use] + pub fn evidence_requirement(&self) -> Option<&str> { + match self { + Self::Evidence { requirement } => Some(requirement), + Self::Breg { .. } => None, + } + } + + #[must_use] + pub fn breg_permissions(&self) -> Option<&[BregPermission]> { + match self { + Self::Evidence { .. } => None, + Self::Breg { permissions } => Some(permissions), + } + } + + fn validate(&self) -> Result<(), ClaimError> { + match self { + Self::Evidence { requirement } => { + validate_bound_value(requirement, MAX_CLAIM_VALUE_BYTES) + .then_some(()) + .ok_or(ClaimError::Malformed(ClaimMember::GrantBounds)) + } + Self::Breg { permissions } => { + if permissions.is_empty() || permissions.len() > MAX_BREG_PERMISSIONS { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut collections = HashSet::with_capacity(permissions.len()); + for permission in permissions { + if !validate_bound_value(&permission.collection, MAX_CLAIM_VALUE_BYTES) + || !collections.insert(permission.collection.as_str()) + || permission.operations.is_empty() + || permission.operations.len() > MAX_BREG_OPERATIONS + { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut operations = HashSet::with_capacity(permission.operations.len()); + if permission.operations.iter().any(|operation| { + !valid_operation(operation) || !operations.insert(operation.as_str()) + }) { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + } + Ok(()) + } + } + } +} + +impl fmt::Debug for GrantBounds { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Evidence { .. } => formatter + .debug_struct("Evidence") + .field("requirement", &"") + .finish(), + Self::Breg { permissions } => formatter + .debug_struct("Breg") + .field("permission_count", &permissions.len()) + .finish(), + } + } +} + +/// Verified, normalized task-grant claims. +#[derive(Clone, Eq, PartialEq)] +pub struct GrantClaims { + principal: String, + id: String, + authority: String, + source_issuer: String, + client: String, + resource: String, + purpose: String, + exp: u64, + bounds: GrantBounds, + approver: Option, +} + +impl GrantClaims { + #[must_use] + pub fn principal(&self) -> &str { + &self.principal + } + + #[must_use] + pub fn id(&self) -> &str { + &self.id + } + + #[must_use] + pub fn authority(&self) -> &str { + &self.authority + } + + #[must_use] + pub fn source_issuer(&self) -> &str { + &self.source_issuer + } + + #[must_use] + pub fn client(&self) -> &str { + &self.client + } + + #[must_use] + pub fn resource(&self) -> &str { + &self.resource + } + + #[must_use] + pub fn purpose(&self) -> &str { + &self.purpose + } + + #[must_use] + pub const fn exp(&self) -> u64 { + self.exp + } + + #[must_use] + pub fn bounds(&self) -> &GrantBounds { + &self.bounds + } + + #[must_use] + pub fn approver(&self) -> Option<&str> { + self.approver.as_deref() + } + + /// Bind immutable grant context to identities already selected by the verifier. + pub fn verify_context( + &self, + token: &VerifiedToken, + verified_resource: &str, + ) -> Result<(), GrantContextError> { + let client = token + .matched_client_id() + .map_err(|_| GrantContextError::InvalidVerifiedClient)? + .ok_or(GrantContextError::MissingVerifiedClient)?; + if client != self.client { + return Err(GrantContextError::ClientMismatch); + } + if verified_resource != self.resource { + return Err(GrantContextError::ResourceMismatch); + } + Ok(()) + } +} + +impl fmt::Debug for GrantClaims { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GrantClaims") + .field("principal", &"") + .field("id", &"") + .field("authority", &"") + .field("source_issuer", &"") + .field("client", &"") + .field("resource", &"") + .field("purpose", &"") + .field("exp", &self.exp) + .field("bounds", &self.bounds) + .field("approver", &self.approver.as_ref().map(|_| "")) + .finish() + } +} + +/// A fixed member label safe to include in errors and metrics. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClaimMember { + ActorKind, + Principal, + Purpose, + TokenExpiration, + GrantId, + GrantAuthority, + GrantSourceIssuer, + GrantClient, + GrantResource, + GrantExpiration, + GrantBounds, + Approver, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[non_exhaustive] +pub enum ClaimError { + #[error("contextual authorization claim names are invalid")] + InvalidNames, + #[error("required contextual authorization claim is missing")] + Missing(ClaimMember), + #[error("task grant claims are incomplete")] + Partial, + #[error("contextual authorization claim is malformed")] + Malformed(ClaimMember), + #[error("task grant has expired")] + Expired, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[non_exhaustive] +pub enum MatchedClientError { + #[error("verified client context is malformed")] + Malformed, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +#[non_exhaustive] +pub enum GrantContextError { + #[error("verified client context is missing")] + MissingVerifiedClient, + #[error("verified client context is malformed")] + InvalidVerifiedClient, + #[error("task grant client does not match the verified client")] + ClientMismatch, + #[error("task grant resource does not match the verified resource")] + ResourceMismatch, +} + +/// Extract the strict actor kind from a verified token. +pub fn actor_kind(claims: &Claims, names: &ClaimNames) -> Result { + names.validate()?; + match claims.extra.get(&names.actor_kind) { + None => Err(ClaimError::Missing(ClaimMember::ActorKind)), + Some(Value::String(value)) if value == "human" => Ok(ActorKind::Human), + Some(Value::String(value)) if value == "agent" => Ok(ActorKind::Agent), + Some(Value::String(value)) if value == "service" => Ok(ActorKind::Service), + Some(_) => Err(ClaimError::Malformed(ClaimMember::ActorKind)), + } +} + +/// Extract a complete task grant from verified claims. +/// +/// Only the seven `registry_grant_*` members detect grant presence. A purpose +/// or actor-kind claim by itself belongs to an ordinary standing service token +/// and returns `Ok(None)`. +pub fn grant_claims( + claims: &Claims, + names: &ClaimNames, + now_unix: u64, +) -> Result, ClaimError> { + names.validate()?; + let core = names.core_grant_names(); + let present = core + .iter() + .filter(|name| claims.extra.contains_key(**name)) + .count(); + if present == 0 { + return Ok(None); + } + if present != core.len() { + return Err(ClaimError::Partial); + } + + let principal = required_standard_string( + claims.sub.as_deref(), + ClaimMember::Principal, + MAX_CLAIM_VALUE_BYTES, + )?; + let id = required_extra_string(claims, &names.grant_id, ClaimMember::GrantId)?; + let authority = + required_extra_string(claims, &names.grant_authority, ClaimMember::GrantAuthority)?; + let source_issuer = required_extra_string( + claims, + &names.grant_source_issuer, + ClaimMember::GrantSourceIssuer, + )?; + let client = required_extra_string(claims, &names.grant_client, ClaimMember::GrantClient)?; + let resource = + required_extra_string(claims, &names.grant_resource, ClaimMember::GrantResource)?; + let purpose = required_extra_string_with_bound( + claims, + &names.purpose, + ClaimMember::Purpose, + MAX_PURPOSE_BYTES, + )?; + if !valid_purpose(&purpose) { + return Err(ClaimError::Malformed(ClaimMember::Purpose)); + } + let token_exp = claims + .exp + .ok_or(ClaimError::Missing(ClaimMember::TokenExpiration)) + .and_then(|value| { + u64::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or(ClaimError::Malformed(ClaimMember::TokenExpiration)) + })?; + let grant_exp = required_unix_timestamp(claims, &names.grant_exp)?; + if now_unix >= token_exp.min(grant_exp) { + return Err(ClaimError::Expired); + } + let bounds_value = claims + .extra + .get(&names.grant_bounds) + .ok_or(ClaimError::Missing(ClaimMember::GrantBounds))?; + let bounds: GrantBounds = serde_json::from_value(bounds_value.clone()) + .map_err(|_| ClaimError::Malformed(ClaimMember::GrantBounds))?; + bounds.validate()?; + let approver = optional_extra_string(claims, &names.approver, ClaimMember::Approver)?; + + Ok(Some(GrantClaims { + principal, + id, + authority, + source_issuer, + client, + resource, + purpose, + exp: grant_exp, + bounds, + approver, + })) +} + +fn required_extra_string( + claims: &Claims, + name: &str, + member: ClaimMember, +) -> Result { + required_extra_string_with_bound(claims, name, member, MAX_CLAIM_VALUE_BYTES) +} + +fn required_extra_string_with_bound( + claims: &Claims, + name: &str, + member: ClaimMember, + maximum_bytes: usize, +) -> Result { + match claims.extra.get(name) { + None => Err(ClaimError::Missing(member)), + Some(Value::String(value)) if valid_text(value, maximum_bytes) => Ok(value.clone()), + Some(_) => Err(ClaimError::Malformed(member)), + } +} + +fn optional_extra_string( + claims: &Claims, + name: &str, + member: ClaimMember, +) -> Result, ClaimError> { + match claims.extra.get(name) { + None => Ok(None), + Some(Value::String(value)) if valid_text(value, MAX_CLAIM_VALUE_BYTES) => { + Ok(Some(value.clone())) + } + Some(_) => Err(ClaimError::Malformed(member)), + } +} + +fn required_standard_string( + value: Option<&str>, + member: ClaimMember, + maximum_bytes: usize, +) -> Result { + match value { + None => Err(ClaimError::Missing(member)), + Some(value) if valid_text(value, maximum_bytes) => Ok(value.to_owned()), + Some(_) => Err(ClaimError::Malformed(member)), + } +} + +fn required_unix_timestamp(claims: &Claims, name: &str) -> Result { + let value = claims + .extra + .get(name) + .ok_or(ClaimError::Missing(ClaimMember::GrantExpiration))?; + value + .as_u64() + .filter(|value| *value > 0 && *value <= i64::MAX as u64) + .ok_or(ClaimError::Malformed(ClaimMember::GrantExpiration)) +} + +fn valid_claim_name(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_CLAIM_NAME_BYTES + && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) + && bytes[1..] + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')) +} + +fn valid_text(value: &str, maximum_bytes: usize) -> bool { + !value.is_empty() && value.len() <= maximum_bytes && !value.chars().any(char::is_control) +} + +fn validate_bound_value(value: &str, maximum_bytes: usize) -> bool { + valid_text(value, maximum_bytes) + && !value.contains('*') + && !value.chars().any(char::is_whitespace) +} + +fn valid_operation(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= MAX_PURPOSE_BYTES + && !value.contains('*') + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} + +fn valid_purpose(value: &str) -> bool { + valid_operation(value) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Map}; + + use super::*; + use crate::Audience; + + fn claims(extra: Value) -> Claims { + Claims { + sub: Some("principal-canary".to_owned()), + iss: Some("https://issuer.example".to_owned()), + aud: Some(Audience::One("urn:registry:breg".to_owned())), + exp: Some(2_000), + iat: Some(1_000), + nbf: None, + azp: Some("agent-client".to_owned()), + client_id: None, + extra: extra.as_object().cloned().unwrap_or_else(Map::new), + } + } + + fn complete_grant(bounds: Value) -> Value { + json!({ + "registry_actor_kind": "agent", + "registry_grant_id": "grant-canary", + "registry_grant_authority": "casework-v1", + "registry_grant_source_issuer": "https://casework.example", + "registry_grant_client": "agent-client", + "registry_grant_resource": "urn:registry:breg", + "registry_grant_exp": 1_900, + "registry_grant_bounds": bounds, + "registry_purpose": "record-review", + "registry_approver": "h:approver-canary" + }) + } + + #[test] + fn standing_service_token_with_purpose_has_no_grant() { + let input = claims(json!({ + "registry_actor_kind": "service", + "registry_purpose": "registry-operations" + })); + assert_eq!( + actor_kind(&input, &ClaimNames::default()), + Ok(ActorKind::Service) + ); + assert_eq!( + grant_claims(&input, &ClaimNames::default(), 1_500), + Ok(None) + ); + } + + #[test] + fn any_partial_core_grant_set_is_rejected() { + for name in ClaimNames::default().core_grant_names() { + let mut extra = Map::new(); + extra.insert(name.to_owned(), json!("present")); + let input = claims(Value::Object(extra)); + assert_eq!( + grant_claims(&input, &ClaimNames::default(), 1_500), + Err(ClaimError::Partial) + ); + } + } + + #[test] + fn custom_direct_names_map_without_nested_path_interpretation() { + let names = ClaimNames { + actor_kind: "custom.actor".to_owned(), + purpose: "custom.purpose".to_owned(), + grant_id: "custom.grant_id".to_owned(), + grant_authority: "custom.grant_authority".to_owned(), + grant_source_issuer: "custom.grant_source_issuer".to_owned(), + grant_client: "custom.grant_client".to_owned(), + grant_resource: "custom.grant_resource".to_owned(), + grant_exp: "custom.grant_exp".to_owned(), + grant_bounds: "custom.grant_bounds".to_owned(), + approver: "custom.approver".to_owned(), + }; + let input = claims(json!({ + "custom.actor": "agent", + "custom.purpose": "record-review", + "custom.grant_id": "grant-canary", + "custom.grant_authority": "casework-v1", + "custom.grant_source_issuer": "https://casework.example", + "custom.grant_client": "agent-client", + "custom.grant_resource": "urn:registry:breg", + "custom.grant_exp": 1900, + "custom.grant_bounds": {"type":"evidence","requirement":"urn:requirement:one"}, + "custom.approver": "h:approver-canary" + })); + let grant = grant_claims(&input, &names, 1_500) + .expect("custom names are valid") + .expect("grant is present"); + assert_eq!(grant.id(), "grant-canary"); + assert_eq!(actor_kind(&input, &names), Ok(ActorKind::Agent)); + } + + #[test] + fn overlapping_or_registered_claim_names_are_rejected() { + let overlapping = ClaimNames { + grant_id: "registry_purpose".to_owned(), + ..ClaimNames::default() + }; + assert_eq!(overlapping.validate(), Err(ClaimError::InvalidNames)); + + let shadowing = ClaimNames { + grant_id: "sub".to_owned(), + ..ClaimNames::default() + }; + assert_eq!(shadowing.validate(), Err(ClaimError::InvalidNames)); + } + + #[test] + fn claim_name_deserialization_rejects_unknown_fields() { + let value = serde_json::to_value(ClaimNames::default()).expect("serializes"); + let mut object = value.as_object().cloned().expect("object"); + object.insert("legacyGrant".to_owned(), json!("legacy_grant")); + assert!(serde_json::from_value::(Value::Object(object)).is_err()); + } + + #[test] + fn partial_claim_name_configuration_keeps_fixed_defaults() { + let names: ClaimNames = serde_json::from_value(json!({"grantId":"foreign_grant"})) + .expect("known partial override is accepted"); + assert_eq!(names.grant_id, "foreign_grant"); + assert_eq!(names.purpose, "registry_purpose"); + assert_eq!(names.grant_bounds, "registry_grant_bounds"); + } + + #[test] + fn actor_kind_is_strict() { + let input = claims(json!({"registry_actor_kind":"Agent"})); + assert_eq!( + actor_kind(&input, &ClaimNames::default()), + Err(ClaimError::Malformed(ClaimMember::ActorKind)) + ); + } + + #[test] + fn expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline() { + let input = claims(complete_grant( + json!({"type":"evidence","requirement":"urn:requirement:one"}), + )); + assert!(grant_claims(&input, &ClaimNames::default(), 1_899).is_ok()); + assert_eq!( + grant_claims(&input, &ClaimNames::default(), 1_900), + Err(ClaimError::Expired) + ); + + let mut earlier_token = input; + earlier_token.exp = Some(1_800); + assert_eq!( + grant_claims(&earlier_token, &ClaimNames::default(), 1_800), + Err(ClaimError::Expired) + ); + } + + #[test] + fn missing_or_non_integer_or_overflowing_expiration_is_rejected() { + let bounds = json!({"type":"evidence","requirement":"urn:requirement:one"}); + let mut missing_token_exp = claims(complete_grant(bounds.clone())); + missing_token_exp.exp = None; + assert_eq!( + grant_claims(&missing_token_exp, &ClaimNames::default(), 1_500), + Err(ClaimError::Missing(ClaimMember::TokenExpiration)) + ); + + let mut missing_grant_exp = complete_grant(bounds.clone()); + missing_grant_exp + .as_object_mut() + .expect("grant object") + .remove("registry_grant_exp"); + assert_eq!( + grant_claims(&claims(missing_grant_exp), &ClaimNames::default(), 1_500), + Err(ClaimError::Partial) + ); + + for invalid in [json!(0), json!(-1), json!(1900.5), json!(u64::MAX)] { + let mut extra = complete_grant(bounds.clone()); + extra["registry_grant_exp"] = invalid; + assert_eq!( + grant_claims(&claims(extra), &ClaimNames::default(), 1_500), + Err(ClaimError::Malformed(ClaimMember::GrantExpiration)) + ); + } + } + + #[test] + fn evidence_and_breg_bounds_are_distinct_and_strict() { + let evidence = claims(complete_grant( + json!({"type":"evidence","requirement":"urn:requirement:one"}), + )); + assert!(matches!( + grant_claims(&evidence, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds(), + GrantBounds::Evidence { .. } + )); + + let breg = claims(complete_grant(json!({ + "type":"breg", + "permissions":[{"collection":"person.reviewer","operations":["get","patch"]}] + }))); + assert!(matches!( + grant_claims(&breg, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds(), + GrantBounds::Breg { .. } + )); + + for invalid in [ + json!({"type":"evidence","requirement":"*"}), + json!({"type":"evidence","requirement":"urn:one","extra":true}), + json!({"type":"breg","permissions":[]}), + json!({"type":"breg","permissions":[{"collection":"person.*","operations":["get"]}]}), + json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":[]}]}), + json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":["*"]}]}), + ] { + assert_eq!( + grant_claims( + &claims(complete_grant(invalid)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + } + + #[test] + fn context_binding_uses_normalized_verified_client_and_exact_resource() { + let input = claims(complete_grant(json!({ + "type":"breg", + "permissions":[{"collection":"person.reviewer","operations":["get"]}] + }))); + let grant = grant_claims(&input, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present"); + let token = VerifiedToken { + claims: input, + matched_client: Some("azp:agent-client".to_owned()), + scopes: Vec::new(), + }; + assert_eq!(grant.verify_context(&token, "urn:registry:breg"), Ok(())); + assert_eq!( + grant.verify_context(&token, "urn:registry:other"), + Err(GrantContextError::ResourceMismatch) + ); + + let client_id_token = VerifiedToken { + claims: token.claims.clone(), + matched_client: Some("client_id:agent-client".to_owned()), + scopes: Vec::new(), + }; + assert_eq!( + grant.verify_context(&client_id_token, "urn:registry:breg"), + Ok(()) + ); + + let malformed = VerifiedToken { + claims: token.claims.clone(), + matched_client: Some("agent-client".to_owned()), + scopes: Vec::new(), + }; + assert_eq!( + grant.verify_context(&malformed, "urn:registry:breg"), + Err(GrantContextError::InvalidVerifiedClient) + ); + } + + #[test] + fn grant_debug_redacts_claim_values() { + let input = claims(complete_grant( + json!({"type":"evidence","requirement":"sensitive-requirement-canary"}), + )); + let grant = grant_claims(&input, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present"); + let rendered = format!("{grant:?}"); + for canary in [ + "principal-canary", + "grant-canary", + "agent-client", + "sensitive-requirement-canary", + "approver-canary", + ] { + assert!(!rendered.contains(canary)); + } + } +} diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index b93ebe1a40..a22ae5d03b 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -5,6 +5,13 @@ //! identity, and scopes; it does not implement browser login, OAuth //! authorization endpoints, PKCE, token minting, or refresh flows. +mod authorization_claims; + +pub use authorization_claims::{ + actor_kind, grant_claims, ActorKind, BregPermission, ClaimError, ClaimMember, ClaimNames, + GrantBounds, GrantClaims, GrantContextError, MatchedClientError, +}; + use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::Arc; @@ -983,6 +990,26 @@ pub struct VerifiedToken { pub scopes: Vec, } +impl VerifiedToken { + /// Return the client id selected by [`TokenVerifier`] without its claim-source prefix. + /// + /// The verifier records whether `azp` or `client_id` supplied the matched + /// value. Authorization consumers normally need the verified client id, + /// but must not accidentally compare a grant with the prefixed internal + /// representation. + pub fn matched_client_id(&self) -> Result, MatchedClientError> { + let Some(matched) = self.matched_client.as_deref() else { + return Ok(None); + }; + matched + .strip_prefix("azp:") + .or_else(|| matched.strip_prefix("client_id:")) + .filter(|client| !client.is_empty()) + .map(Some) + .ok_or(MatchedClientError::Malformed) + } +} + #[derive(Debug)] pub struct TokenVerifier { config: TokenVerifierConfig, @@ -1444,16 +1471,11 @@ fn issuer_from_untrusted_payload(token: &str) -> Option { } fn matched_client_audience(access_token: &VerifiedToken) -> Result { - let matched_client = access_token - .matched_client - .as_deref() - .ok_or(OidcError::ClientNotAllowed)?; - matched_client - .strip_prefix("azp:") - .or_else(|| matched_client.strip_prefix("client_id:")) - .filter(|audience| !audience.is_empty()) - .map(ToOwned::to_owned) + access_token + .matched_client_id() + .map_err(|_| OidcError::ClientNotAllowed)? .ok_or(OidcError::ClientNotAllowed) + .map(ToOwned::to_owned) } #[derive(Debug, thiserror::Error)] diff --git a/crates/registry-thunderid-tooling/Cargo.toml b/crates/registry-thunderid-tooling/Cargo.toml new file mode 100644 index 0000000000..12b5e2d51b --- /dev/null +++ b/crates/registry-thunderid-tooling/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "registry-thunderid-tooling" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Tooling-only support for adopter CLIs and integration tests against a pinned upstream ThunderID issuer." +repository.workspace = true +readme = "README.md" +publish = false + +[lints] +workspace = true + +# This crate is deliberately private: no runtime product may depend on it, and +# it ships no binary and opens no listener. Adopter CLIs and integration tests +# are its only consumers. It renders the pinned upstream release's native +# declarative resources from a validated internal description, performs the +# upstream bootstrap one-shot, and owns one development session's container. +[dependencies] +base64.workspace = true +getrandom.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httputil.workspace = true +reqwest.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +sha2.workspace = true +thiserror.workspace = true +url.workspace = true + +[dev-dependencies] +p256.workspace = true +registry-platform-authcommon.workspace = true diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md new file mode 100644 index 0000000000..8bcac625f8 --- /dev/null +++ b/crates/registry-thunderid-tooling/README.md @@ -0,0 +1,111 @@ +# registry-thunderid-tooling + +Private, non-published tooling support for adopter CLIs and integration tests +that need a pinned, unmodified upstream ThunderID issuer. It is not a runtime +product: it opens no listener, ships no binary, and no BREG, Evidence, Relay, +or OID4VCI runtime crate may depend on it. + +## What it does + +1. Loads the single upstream pin from `thunderid-version.json`. +2. Validates a product-neutral internal description + (`description::IssuerDescription`) supplied by the owning CLI, which keeps + every authority decision on its side. +3. Renders the pinned release's native declarative resources (`render`), + including the `default` agent-schema update derived additively from the + pinned bundle, preserving every upstream field. +4. Performs the explicit upstream bootstrap one-shot + (`bootstrap::Bootstrap::apply_agent_schema`) with `--defaults` and a + file-injected throwaway `ADMIN_PASSWORD`. +5. Owns one development session's container lifecycle + (`container::Session`): one-time setup, start/stop with retained state, + ownership labels, and an explicitly requested destructive reset. +6. Reads back the public endpoints and registration information + (`issuer::IssuerEndpoints`). No secret, key, or token is ever returned, + logged, or placed in an argv element. + +## Regenerating the stored fixture + +```bash +cargo run -p registry-thunderid-tooling --example render-fixture -- \ + products/identity/thunderid/fixtures/synthetic-session +``` + +The committed fixture under `products/identity/thunderid/fixtures/` must come +from this command, never from hand editing. + +## Institutional token exchange + +`IssuerDescription.exchange_issuers` registers external grant authorities as +native `connection` resources. The connection fixes user-type resolution to an +internal mapping label and copies the verified subject-token `iss` into +`registry_grant_source_issuer`. Incoming claims cannot select a different +mapping or replace that derived value. The development container loads identity +providers only from declarative resources. + +A machine client's `token_exchange: Some(TokenExchangeClient { ... })` enables +`client_credentials` and RFC 8693 token exchange on the same registered key. +Every role assigned to that client must contain only its configured authority +assertion lookup permission. Client credentials uses `clientConfig`; exchange +uses `userConfig`, whose closed allowlist carries the signed task fields, +including JSON identity selectors, product-specific bounds and numeric expiry. +Static schemas and client attributes cannot contain `registry_grant_*`, +`identity`, or `registry_approver`. Ordinary actor-kind and purpose attributes +remain available for non-grant service profiles. + +These declarations transport authority; each resource server still compares +its authenticated client and configured resource against the immutable grant +client and destination, binds the source issuer to its permitted authority, +checks its native permissions and selectors, and refuses the earlier of token +expiry and grant deadline. Re-exchange preserves the original deadline. A +bootstrap access token contains no task grant and cannot acquire one by +exchanging itself. + +The reproducible real-container proof is: + +```bash +products/identity/scripts/test-contextual-exchange.py +``` + +It builds the `contextual-exchange` example with locked dependencies, selects the +immutable upstream image pin, allocates a fresh owner-only state directory and +uniquely labelled container, and serves generated public authority JWKS from the +host. It retains its state and stops only its own container. A prebuilt driver +can be supplied with `--driver`; `--state` must name an absent absolute path. +The current host-network proof uses Docker Desktop/OrbStack's +`host.docker.internal` resolution. + +The cases verify the shared private-key-JWT provider, nested Evidence and BREG +bounds, an unregistered assertion subject, signed issuer provenance, unavailable +or unknown issuers, missing grant fields, scope narrowing, token type and +assertion audience, immutable client/resource bounds, original deadline through +re-exchange, and bootstrap isolation. Cross-client and cross-resource exchanges +can issue tokens carrying the original bounds; the driver explicitly distinguishes +that transport proof from resource-server enforcement. Resource-server tests +remain owned by their products. No key, assertion, bearer token or protected +response body is printed or passed in process arguments. + +## Shared local-session construction + +`local::local_description` constructs one resource audience, exact colon-delimited +scope trees, per-client roles and public-key registrations for adopter CLI dev +sessions. `local::agent_id` returns the stable native principal used to seed a +local directory and identify the resulting token's subject. The helper refuses +unrepresentable or ambiguous scope trees rather than rewriting permissions. + +`LocalClient.allow_human_fixture` must be explicit when teaching fixtures need a +human marker on a local machine token. This option neither creates production +human sessions nor relaxes a consuming runtime's human-session checks. Task +attributes remain forbidden even for these fixtures. + +`local::start(&Session, docker_path, cancellation_callback)` bootstraps already +rendered state, waits for the exact loopback issuer, and returns a bounded public +RS256 JWKS snapshot. `local::stop` selects only the exact session-owned container. +The shared runner bounds commands and HTTP responses, reads owner-only secret +files into the child environment, and discards command output other than owned +container IDs. Product CLIs retain their own policy, status UI, and cancellation +flag. Neither operation removes retained files. + +### Citizen delegation + +Citizen authorization-code delegation uses the reviewed native patch and rebuilt Gate in [the native extension](../../products/identity/thunderid/extension/README.md). `citizen::render` appends the closed provider, human type, consent flow and agent registration before `local::start`; use the build metadata's immutable candidate image ID. Copy the exact destination resource from its client export. This path always requires fresh purpose/field consent and admits no institutional grant namespace. diff --git a/crates/registry-thunderid-tooling/examples/contextual-exchange.rs b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs new file mode 100644 index 0000000000..fd660c6e75 --- /dev/null +++ b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs @@ -0,0 +1,937 @@ +//! Real pinned-container RFC 8693 authority-preservation proof. +//! Keys, assertions and bearer tokens stay in memory. Direct fixed-form probes +//! exercise invalid requests; no protected response body is printed. +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_authcommon::client_assertion::{ + sign_client_assertion, ClientAssertionRequest, +}; +use registry_platform_crypto::{PrivateJwk, PublicJwk}; +use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig}; +use registry_thunderid_tooling::{ + container::Session, description::*, local, render, version::ThunderIdPin, +}; +use serde_json::{json, Value}; + +type Result = std::result::Result; +const TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT: &str = "urn:ietf:params:oauth:token-type:jwt"; +const ACCESS: &str = "urn:ietf:params:oauth:token-type:access_token"; +const TARGET: &str = "urn:registry:gate0:target"; +const OTHER: &str = "urn:registry:gate0:other"; +const AUTHORITY_RESOURCE: &str = "urn:registry:gate0:authority"; +const CLIENT: &str = "gate0-agent-a"; +const SECOND_CLIENT: &str = "gate0-agent-b"; +const AUTHORITY_ID: &str = "0197aaaa-0000-7000-8000-0000000000b3"; +const BOOTSTRAP_SCOPE: &str = "grants:assert"; +const TARGET_SCOPE: &str = "records:get"; + +fn check(condition: bool, reason: &'static str) -> Result<()> { + if condition { + Ok(()) + } else { + Err(reason) + } +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_secs() +} +fn random() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("randomness"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} +fn key(kid: &str) -> (PrivateJwk, String) { + let secret = p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = secret.verifying_key().to_encoded_point(false); + let public = json!({"kty":"EC","crv":"P-256","alg":"ES256","kid":kid,"use":"sig", + "x":URL_SAFE_NO_PAD.encode(point.x().expect("x")),"y":URL_SAFE_NO_PAD.encode(point.y().expect("y"))}); + let mut private = public.clone(); + private["d"] = json!(URL_SAFE_NO_PAD.encode(secret.to_bytes())); + ( + PrivateJwk::parse(&private.to_string()).expect("generated key"), + json!({"keys":[public]}).to_string(), + ) +} +fn signed(key: &PrivateJwk, kid: &str, claims: &Value) -> Result { + let header = URL_SAFE_NO_PAD.encode(json!({"alg":"ES256","typ":"JWT","kid":kid}).to_string()); + let payload = URL_SAFE_NO_PAD.encode(claims.to_string()); + let input = format!("{header}.{payload}"); + let signature = registry_platform_crypto::sign(input.as_bytes(), key) + .map_err(|_| "assertion signing failed")?; + Ok(format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature))) +} + +struct PublicKeys { + stop: Arc, + requests: Arc, + port: u16, + thread: Option>, +} +impl PublicKeys { + fn start(a: String, b: String) -> Result { + // Only generated public JWKS are served. Docker Desktop resolves this + // host through host.docker.internal; no protected endpoint lives here. + let listener = + TcpListener::bind(("0.0.0.0", 0)).map_err(|_| "JWKS listener unavailable")?; + let port = listener + .local_addr() + .map_err(|_| "JWKS address unavailable")? + .port(); + listener + .set_nonblocking(true) + .map_err(|_| "JWKS listener setup failed")?; + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(AtomicUsize::new(0)); + let running = stop.clone(); + let count = requests.clone(); + let thread = std::thread::spawn(move || { + while !running.load(Ordering::Relaxed) { + if let Ok((mut stream, _)) = listener.accept() { + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut bytes = [0u8; 4096]; + let n = stream.read(&mut bytes).unwrap_or(0); + let request = String::from_utf8_lossy(&bytes[..n]); + let body = if request.starts_with("GET /a/jwks ") { + Some(&a) + } else if request.starts_with("GET /b/jwks ") { + Some(&b) + } else { + None + }; + if let Some(body) = body { + count.fetch_add(1, Ordering::Relaxed); + let _=write!(stream,"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",body.len(),body); + } else { + let _=stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + } else { + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + Ok(Self { + stop, + requests, + port, + thread: Some(thread), + }) + } + fn issuer(&self, which: &str) -> String { + format!("http://host.docker.internal:{}/{which}", self.port) + } +} +impl Drop for PublicKeys { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn resource(id: &str, identifier: &str, handle: &str, action: &str) -> ResourceServer { + ResourceServer { + id: id.into(), + name: format!("Gate0 {handle} {id}"), + identifier: identifier.into(), + description: "Synthetic acceptance resource".into(), + resources: vec![Resource { + name: handle.into(), + handle: handle.into(), + parent: None, + description: "Acceptance resource".into(), + actions: vec![Action { + name: action.into(), + handle: action.into(), + description: "Acceptance operation".into(), + }], + }], + } +} +fn description( + root: &Path, + port: u16, + id: &str, + keys: &PublicKeys, + client_keys: [String; 2], +) -> IssuerDescription { + let machine_clients = client_keys + .into_iter() + .enumerate() + .map(|(i, public_jwks)| MachineClient { + agent_id: format!("0197aaaa-0000-7000-8000-0000000000a{}", i + 1), + name: format!("Gate0 Agent {i}"), + description: "Synthetic agent".into(), + client_id: if i == 0 { CLIENT } else { SECOND_CLIENT }.into(), + public_jwks, + attributes: BTreeMap::new(), + token_attributes: vec![], + access_token_lifetime_seconds: 300, + token_exchange: Some(TokenExchangeClient { + assertion_resource_server_id: AUTHORITY_ID.into(), + assertion_scope: BOOTSTRAP_SCOPE.into(), + }), + }) + .collect(); + IssuerDescription { + session: SessionIdentity { + label: format!("gate0-{}", &id[..12]), + id: id.into(), + }, + port, + state_root: root.into(), + organization_unit: OrganizationUnit { + id: DEFAULT_OU_ID.into(), + handle: DEFAULT_OU_HANDLE.into(), + name: "Default".into(), + description: "Default".into(), + }, + resource_servers: vec![ + resource( + "0197aaaa-0000-7000-8000-0000000000b1", + TARGET, + "records", + "get", + ), + resource( + "0197aaaa-0000-7000-8000-0000000000b2", + OTHER, + "records", + "get", + ), + resource(AUTHORITY_ID, AUTHORITY_RESOURCE, "grants", "assert"), + ], + roles: vec![Role { + id: "0197aaaa-0000-7000-8000-0000000000c1".into(), + name: "Gate0 Bootstrap".into(), + description: "Assertion lookup only".into(), + permissions: vec![(AUTHORITY_ID.into(), vec![BOOTSTRAP_SCOPE.into()])], + assigned_agents: vec![ + "0197aaaa-0000-7000-8000-0000000000a1".into(), + "0197aaaa-0000-7000-8000-0000000000a2".into(), + ], + }], + machine_clients, + compatibility_clients: vec![], + schema_attributes: vec![], + exchange_issuers: ["a", "b", "unavailable"] + .iter() + .enumerate() + .map(|(i, which)| ExchangeIssuer { + id: format!("0197aaaa-0000-7000-8000-0000000000d{}", i + 1), + name: format!("Gate0 Authority {which}"), + issuer: keys.issuer(which), + jwks_endpoint: format!("{}/jwks", keys.issuer(which)), + }) + .collect(), + } +} +struct Running { + description: IssuerDescription, + pin: ThunderIdPin, +} +impl Running { + fn session(&self) -> Session<'_> { + Session { + label: &self.description.session.label, + id: &self.description.session.id, + port: self.description.port, + state_root: &self.description.state_root, + image: &self.pin.image, + } + } + fn start(description: IssuerDescription, image: Option<&str>) -> Result { + description + .validate() + .map_err(|_| "Gate0 description validation failed")?; + let mut pin = ThunderIdPin::load().map_err(|_| "upstream pin invalid")?; + if let Some(image) = image { + check( + image.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) + }), + "candidate image must be an immutable local image ID", + )?; + pin.image = image.to_owned(); + } + let this = Self { description, pin }; + let root = &this.description.state_root; + std::fs::create_dir_all(root.join("secrets")) + .map_err(|_| "private directory creation failed")?; + for name in ["direct_auth_secret", "throwaway-bootstrap-password"] { + let path = root.join("secrets").join(name); + std::fs::write(&path, random()).map_err(|_| "secret initialization failed")?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|_| "secret permissions failed")?; + } + render::render(&this.description).map_err(|_| "native resource rendering failed")?; + if image.is_some() { + let provider = format!( + "http://host.docker.internal:{}", + this.description.exchange_issuers[0] + .issuer + .rsplit(':') + .next() + .and_then(|s| s.split('/').next()) + .unwrap_or("1") + ); + let (_, public_jwks) = key("citizen-client"); + registry_thunderid_tooling::citizen::render( + &this.description, + ®istry_thunderid_tooling::citizen::Federation { + id: "0197aaaa-0000-7000-8000-0000000000d9".into(), + name: "Synthetic citizen federation".into(), + issuer: provider.clone(), + authorization_endpoint: format!("{provider}/authorize"), + token_endpoint: format!("{provider}/token"), + userinfo_endpoint: format!("{provider}/userinfo"), + jwks_endpoint: format!("{provider}/a/jwks"), + client_id: "citizen-upstream".into(), + redirect_uri: format!("http://127.0.0.1:{}/gate/signin", this.description.port), + id_token_alg: "PS256".into(), + userinfo_alg: "PS256".into(), + }, + ®istry_thunderid_tooling::citizen::Client { + client_id: "citizen-agent".into(), + name: "Citizen self-service agent".into(), + public_jwks, + redirect_uri: "http://127.0.0.1:8901/callback".into(), + resource: TARGET.into(), + purpose: "Citizen status lookup".into(), + scope_fields: BTreeMap::from([( + TARGET_SCOPE.into(), + vec!["person-reference".into(), "status".into()], + )]), + require_active_identity: true, + }, + ) + .map_err(|_| "native citizen resource rendering failed")?; + } + local::start(&this.session(), std::path::Path::new("docker"), &mut || { + false + }) + .map_err(|_| "owned issuer bootstrap/start/public JWKS checks failed")?; + Ok(this) + } + fn issuer(&self) -> String { + format!("http://127.0.0.1:{}", self.description.port) + } +} +impl Drop for Running { + fn drop(&mut self) { + let _ = local::stop(&self.session(), std::path::Path::new("docker")); + } +} + +fn http(url: &str, form: Option<&[(String, String)]>) -> Result<(u16, Value)> { + let mut command = Command::new("curl"); + command.args([ + "--silent", + "--max-time", + "10", + "--write-out", + "\n%{http_code}", + ]); + if form.is_some() { + command.args([ + "--header", + "Content-Type: application/x-www-form-urlencoded", + "--data-binary", + "@-", + ]); + } + command + .arg(url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut process = command.spawn().map_err(|_| "HTTP probe unavailable")?; + if let Some(form) = form { + let mut encoder = url::form_urlencoded::Serializer::new(String::new()); + for (k, v) in form { + encoder.append_pair(k, v); + } + process + .stdin + .take() + .ok_or("HTTP stdin unavailable")? + .write_all(encoder.finish().as_bytes()) + .map_err(|_| "HTTP request failed")?; + } + let output = process + .wait_with_output() + .map_err(|_| "HTTP probe failed")?; + check(output.status.success(), "HTTP transport failed")?; + let text = String::from_utf8(output.stdout).map_err(|_| "HTTP response encoding invalid")?; + let (body, status) = text.rsplit_once('\n').ok_or("HTTP status missing")?; + let status = status.parse().map_err(|_| "HTTP status invalid")?; + Ok((status, serde_json::from_str(body).unwrap_or(Value::Null))) +} +fn post( + issuer: &str, + key: &PrivateJwk, + client: &str, + subject: Option<(&str, &str)>, + resource: &str, + scope: &str, +) -> Result<(u16, Value)> { + let assertion = sign_client_assertion( + key, + &ClientAssertionRequest { + client_id: client, + audience: issuer, + lifetime_seconds: 60, + issued_at: now() as i64, + }, + ) + .map_err(|_| "client assertion signing failed")?; + let mut form = vec![ + ( + "grant_type".into(), + if subject.is_some() { + TOKEN_EXCHANGE + } else { + "client_credentials" + } + .into(), + ), + ("client_id".into(), client.into()), + ( + "client_assertion_type".into(), + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".into(), + ), + ("client_assertion".into(), assertion.as_str().into()), + ("resource".into(), resource.into()), + ("scope".into(), scope.into()), + ]; + if let Some((token, typ)) = subject { + form.extend([ + ("subject_token".into(), token.into()), + ("subject_token_type".into(), typ.into()), + ("requested_token_type".into(), ACCESS.into()), + ]); + } + http(&format!("{issuer}/oauth2/token"), Some(&form)) +} +fn token(response: &Value) -> Result<&str> { + response["access_token"] + .as_str() + .ok_or("access token missing") +} +fn verified(token: &str, jwks: &Value) -> Result { + let parts: Vec<_> = token.split('.').collect(); + check(parts.len() == 3, "token is not a compact JWS")?; + let decode = |s: &str| { + URL_SAFE_NO_PAD + .decode(s) + .map_err(|_| "token encoding invalid") + }; + let header: Value = + serde_json::from_slice(&decode(parts[0])?).map_err(|_| "token header invalid")?; + check(header["typ"] == "at+jwt", "access token type invalid")?; + let value = jwks["keys"] + .as_array() + .ok_or("issuer JWKS missing")? + .iter() + .find(|jwk| jwk["kid"] == header["kid"]) + .ok_or("issuer signing key missing")?; + let jwk = PublicJwk::parse(&value.to_string()).map_err(|_| "issuer key invalid")?; + check( + jwk.alg + .as_deref() + .is_none_or(|alg| Some(alg) == header["alg"].as_str()), + "issuer algorithm mismatch", + )?; + registry_platform_crypto::verify( + format!("{}.{}", parts[0], parts[1]).as_bytes(), + &decode(parts[2])?, + &jwk, + ) + .map_err(|_| "issuer signature invalid")?; + serde_json::from_slice(&decode(parts[1])?).map_err(|_| "token claims invalid") +} +fn grant(issuer: &str, source: &str) -> Value { + let time = now(); + json!({"iss":source,"aud":issuer,"sub":"unregistered-task-agent-subject","iat":time,"exp":time+60,"jti":random(), + "scope":TARGET_SCOPE,"registry_actor_kind":"agent","registry_grant_id":"g-synthetic-1","registry_grant_authority":"authority-a", + "registry_grant_client":CLIENT,"registry_grant_resource":TARGET,"registry_purpose":"synthetic-check","registry_grant_exp":time+900, + "registry_grant_bounds":{"type":"evidence","requirement":"synthetic-requirement"}, + "identity":{"demographics-v1":{"full_name":"Synthetic Canary","birth_date":"1998-04-02"}},"registry_approver":"h:synthetic-approver"}) +} +fn bounds(claims: &Value, client: &str, resource: &str, source: &str, time: u64) -> bool { + claims["client_id"] == client + && claims["registry_grant_client"] == client + && claims["registry_grant_resource"] == resource + && claims["registry_grant_source_issuer"] == source + && claims["registry_grant_authority"] == "authority-a" + && claims["registry_grant_exp"] + .as_u64() + .is_some_and(|exp| time < exp) + && claims["exp"].as_u64().is_some_and(|exp| time < exp) + && claims["registry_grant_id"] + .as_str() + .is_some_and(|id| !id.is_empty()) +} +fn exchanged( + issuer: &str, + key: &PrivateJwk, + client: &str, + subject: &str, + resource: &str, + scope: &str, + jwks: &Value, +) -> Result<(String, Value)> { + let (status, response) = post(issuer, key, client, Some((subject, JWT)), resource, scope)?; + check(status == 200, "exchange rejected")?; + check( + response["issued_token_type"] == ACCESS, + "issued token type missing or incorrect", + )?; + check( + response.get("refresh_token").is_none(), + "unexpected refresh token", + )?; + let compact = token(&response)?.to_owned(); + let claims = verified(&compact, jwks)?; + Ok((compact, claims)) +} +fn run(root: &Path, image: Option<&str>) -> Result<()> { + std::fs::create_dir(root) + .map_err(|_| "state must be a fresh directory; existing state is never removed")?; + std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)) + .map_err(|_| "state permissions failed")?; + let (client_key, client_jwks) = key("client-a"); + let (second_key, second_jwks) = key("client-b"); + let (authority_key, authority_jwks) = key("authority-a"); + let (other_key, other_jwks) = key("authority-b"); + let keys = PublicKeys::start(authority_jwks, other_jwks)?; + let socket = TcpListener::bind(("127.0.0.1", 0)).map_err(|_| "issuer port unavailable")?; + let port = socket + .local_addr() + .map_err(|_| "issuer port unavailable")? + .port(); + drop(socket); + let started = Instant::now(); + let live = Running::start( + description(root, port, &random(), &keys, [client_jwks, second_jwks]), + image, + )?; + let issuer = live.issuer(); + let discovery = loop { + if let Ok((200, body)) = http(&format!("{issuer}/.well-known/openid-configuration"), None) { + break body; + } + if started.elapsed() > Duration::from_secs(120) { + return Err("issuer readiness deadline exceeded"); + } + std::thread::sleep(Duration::from_millis(200)); + }; + check(discovery["issuer"] == issuer, "discovery issuer mismatch")?; + let uri = discovery["jwks_uri"] + .as_str() + .ok_or("JWKS discovery missing")?; + check( + uri.starts_with(&format!("{issuer}/")), + "unexpected JWKS origin", + )?; + let (status, jwks) = http(uri, None)?; + check(status == 200, "issuer JWKS unavailable")?; + println!( + "PASS Gate0.readiness ({} ms)", + started.elapsed().as_millis() + ); + let original = grant(&issuer, &keys.issuer("a")); + let assertion = signed(&authority_key, "authority-a", &original)?; + let (access, claims) = exchanged( + &issuer, + &client_key, + CLIENT, + &assertion, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + for name in GRANT_ATTRIBUTES + .iter() + .copied() + .filter(|name| *name != "registry_grant_source_issuer") + { + check( + claims[name] == original[name], + "grant attribute changed or dropped", + )?; + } + check( + claims["sub"] == original["sub"], + "unregistered assertion subject changed", + )?; + check(claims["iss"] == issuer, "access issuer mismatch")?; + check(claims["scope"] == TARGET_SCOPE, "scope changed")?; + check( + claims["exp"] + .as_u64() + .zip(claims["iat"].as_u64()) + .is_some_and(|(exp, iat)| exp - iat == 300), + "access lifetime differs from configured 300 seconds", + )?; + check( + bounds(&claims, CLIENT, TARGET, &keys.issuer("a"), now()), + "correct bound token refused by expected consumer comparisons", + )?; + println!("PASS Gate0.jwt-nested-claims-and-unknown-subject"); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{issuer}/oauth2/token") + .parse() + .map_err(|_| "token endpoint invalid")?, + CLIENT, + client_key.clone(), + ) + .with_audience(issuer.clone()) + .with_resource(TARGET) + .with_scopes([TARGET_SCOPE]), + ) + .map_err(|_| "shared provider configuration refused")?; + let runtime = + tokio::runtime::Runtime::new().map_err(|_| "shared provider runtime unavailable")?; + let provided = runtime + .block_on(provider.exchange(&assertion)) + .map_err(|_| "shared provider exchange refused")?; + let header = provided.authorization_header_value(); + let compact = header + .to_str() + .map_err(|_| "shared provider token encoding invalid")? + .strip_prefix("Bearer ") + .ok_or("shared provider bearer prefix missing")?; + let provided_claims = verified(compact, &jwks)?; + check( + bounds(&provided_claims, CLIENT, TARGET, &keys.issuer("a"), now()), + "shared provider lost grant bounds", + )?; + println!("PASS Gate0.shared-private-key-jwt-provider"); + + let mut breg = original.clone(); + breg["registry_grant_bounds"] = json!({"type":"breg","permissions":[{"collection":"synthetic_records","operations":["get","list","submit_request"]}]}); + let breg_assertion = signed(&authority_key, "authority-a", &breg)?; + let (_, breg_claims) = exchanged( + &issuer, + &client_key, + CLIENT, + &breg_assertion, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + breg_claims["registry_grant_bounds"] == breg["registry_grant_bounds"], + "nested BREG permissions dropped", + )?; + println!("PASS Gate0.breg-permission-bounds"); + let mut spoofed = original.clone(); + spoofed["iss"] = json!(keys.issuer("b")); + spoofed["registry_grant_source_issuer"] = json!(keys.issuer("a")); + let forged = signed(&other_key, "authority-b", &spoofed)?; + let (_, other_claims) = exchanged( + &issuer, + &client_key, + CLIENT, + &forged, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + other_claims["registry_grant_source_issuer"] == keys.issuer("b"), + "external issuer provenance spoof survived mapping", + )?; + check( + !bounds(&other_claims, CLIENT, TARGET, &keys.issuer("a"), now()), + "another issuer acquired authority A", + )?; + let wrong_key = signed(&other_key, "authority-b", &original)?; + check( + post( + &issuer, + &client_key, + CLIENT, + Some((&wrong_key, JWT)), + TARGET, + TARGET_SCOPE, + )? + .0 != 200, + "wrong authority signing key accepted", + )?; + println!("PASS Gate0.provenance-and-issuer-confusion"); + for source in [keys.issuer("unregistered"), keys.issuer("unavailable")] { + let mut input = original.clone(); + input["iss"] = json!(source); + let input = signed(&authority_key, "authority-a", &input)?; + check( + post( + &issuer, + &client_key, + CLIENT, + Some((&input, JWT)), + TARGET, + TARGET_SCOPE, + )? + .0 != 200, + "unknown or unavailable authority accepted", + )?; + } + let mut missing = original.clone(); + missing + .as_object_mut() + .ok_or("grant fixture malformed")? + .remove("registry_grant_id"); + let missing = signed(&authority_key, "authority-a", &missing)?; + let (_, missing_claims) = exchanged( + &issuer, + &client_key, + CLIENT, + &missing, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + missing_claims.get("registry_grant_id").is_none() + && !bounds(&missing_claims, CLIENT, TARGET, &keys.issuer("a"), now()), + "missing grant ID was manufactured during exchange", + )?; + println!("PASS Gate0.unregistered-unavailable-and-missing-grant"); + + let (_, cross_client) = exchanged( + &issuer, + &second_key, + SECOND_CLIENT, + &assertion, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + cross_client["registry_grant_client"] == CLIENT, + "original grant client overwritten", + )?; + check( + !bounds( + &cross_client, + SECOND_CLIENT, + TARGET, + &keys.issuer("a"), + now(), + ), + "retargeted client escaped immutable bound", + )?; + let (_, cross_resource) = exchanged( + &issuer, + &client_key, + CLIENT, + &assertion, + OTHER, + TARGET_SCOPE, + &jwks, + )?; + check( + cross_resource["registry_grant_resource"] == TARGET, + "original grant resource overwritten", + )?; + check( + !bounds(&cross_resource, CLIENT, OTHER, &keys.issuer("a"), now()), + "retargeted resource escaped immutable bound", + )?; + println!( + "PASS Gate0.cross-client-and-resource-preserve-bounds (consumer enforcement required)" + ); + let (_, renewed) = exchanged( + &issuer, + &client_key, + CLIENT, + &access, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + renewed["registry_grant_source_issuer"] == claims["registry_grant_source_issuer"] + && renewed["registry_grant_exp"] == claims["registry_grant_exp"], + "re-exchange changed original provenance or deadline", + )?; + let mut short = original.clone(); + short["registry_grant_exp"] = json!(now() + 2); + let short_assertion = signed(&authority_key, "authority-a", &short)?; + let (short_access, _) = exchanged( + &issuer, + &client_key, + CLIENT, + &short_assertion, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + std::thread::sleep(Duration::from_secs(3)); + let (_, past_deadline) = exchanged( + &issuer, + &client_key, + CLIENT, + &short_access, + TARGET, + TARGET_SCOPE, + &jwks, + )?; + check( + past_deadline["registry_grant_exp"] == short["registry_grant_exp"] + && !bounds(&past_deadline, CLIENT, TARGET, &keys.issuer("a"), now()), + "re-exchange extended usable authority deadline", + )?; + println!( + "PASS Gate0.immutable-deadline-through-reexchange (consumer expiry enforcement required)" + ); + let (status, bootstrap) = post( + &issuer, + &client_key, + CLIENT, + None, + AUTHORITY_RESOURCE, + BOOTSTRAP_SCOPE, + )?; + check(status == 200, "bootstrap client credentials refused")?; + let bootstrap_claims = verified(token(&bootstrap)?, &jwks)?; + check( + bootstrap_claims["scope"] == BOOTSTRAP_SCOPE + && GRANT_ATTRIBUTES + .iter() + .all(|name| bootstrap_claims.get(*name).is_none()), + "bootstrap token carried grant authority", + )?; + let (_, bootstrap_exchange) = exchanged( + &issuer, + &client_key, + CLIENT, + token(&bootstrap)?, + AUTHORITY_RESOURCE, + BOOTSTRAP_SCOPE, + &jwks, + )?; + check( + GRANT_ATTRIBUTES + .iter() + .all(|name| bootstrap_exchange.get(*name).is_none()), + "bootstrap re-exchange manufactured authority", + )?; + let (status, target_bootstrap) = + post(&issuer, &client_key, CLIENT, None, TARGET, TARGET_SCOPE)?; + if status == 200 { + let c = verified(token(&target_bootstrap)?, &jwks)?; + check( + c["scope"] != TARGET_SCOPE && !bounds(&c, CLIENT, TARGET, &keys.issuer("a"), now()), + "client credentials acquired target authority", + )?; + } + println!("PASS Gate0.bootstrap-and-reexchange-have-no-grants"); + let (_, narrowed) = exchanged( + &issuer, + &client_key, + CLIENT, + &assertion, + TARGET, + "records:get records:patch", + &jwks, + )?; + check( + narrowed["scope"] == TARGET_SCOPE, + "exchange widened requested scope", + )?; + check( + post( + &issuer, + &client_key, + CLIENT, + Some((&assertion, ACCESS)), + TARGET, + TARGET_SCOPE, + )? + .0 != 200, + "JWT assertion accepted as at+jwt access token", + )?; + let mut expired = original.clone(); + expired["exp"] = json!(now() - 120); + expired["iat"] = json!(now() - 180); + let expired = signed(&authority_key, "authority-a", &expired)?; + check( + post( + &issuer, + &client_key, + CLIENT, + Some((&expired, JWT)), + TARGET, + TARGET_SCOPE, + )? + .0 != 200, + "expired assertion accepted", + )?; + let mut wrong_audience = original.clone(); + wrong_audience["aud"] = json!("urn:wrong-audience"); + let wrong_audience = signed(&authority_key, "authority-a", &wrong_audience)?; + check( + post( + &issuer, + &client_key, + CLIENT, + Some((&wrong_audience, JWT)), + TARGET, + TARGET_SCOPE, + )? + .0 != 200, + "wrong assertion audience accepted", + )?; + check( + keys.requests.load(Ordering::Relaxed) >= 2, + "container did not fetch both authority JWKS over host networking", + )?; + println!("PASS Gate0.scopes-token-types-expiry-audience-and-host-jwks"); + println!("PASS Gate0.complete (pinned issuer; resource-server tests remain separate)"); + Ok(()) +} +fn main() { + let mut args = std::env::args().skip(1); + let root = match (args.next().as_deref(), args.next()) { + (Some("--state"), Some(root)) => PathBuf::from(root), + _ => { + eprintln!("usage: contextual-exchange --state FRESH_ABSOLUTE_DIRECTORY [--image sha256:LOCAL_IMAGE_ID]"); + std::process::exit(2); + } + }; + let image = match (args.next().as_deref(), args.next(), args.next()) { + (None, None, None) => None, + (Some("--image"), Some(image), None) => Some(image), + _ => { + eprintln!("invalid candidate image arguments"); + std::process::exit(2); + } + }; + if !root.is_absolute() { + eprintln!("state must be absolute"); + std::process::exit(2); + } + if let Err(reason) = run(&root, image.as_deref()) { + eprintln!("FAIL Gate0: {reason}"); + std::process::exit(1); + } +} diff --git a/crates/registry-thunderid-tooling/examples/integration.rs b/crates/registry-thunderid-tooling/examples/integration.rs new file mode 100644 index 0000000000..c827669cf6 --- /dev/null +++ b/crates/registry-thunderid-tooling/examples/integration.rs @@ -0,0 +1,678 @@ +//! Real-container integration driver for the P3 acceptance subcases. +//! +//! Invoked by `products/identity/scripts/test-thunderid-integration.py` as a +//! built test binary (`--case A09.schema-and-restart` and friends). It drives +//! one owned ThunderID session through the tooling crate and performs token +//! exchanges with the shared platform client — it implements no OAuth client +//! of its own. It creates and cleans up only its own synthetic resources, +//! publishes only on numeric loopback, and never prints a secret, key, +//! assertion, or token. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine as _; +use registry_platform_crypto::PrivateJwk; +use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; +use registry_thunderid_tooling::bootstrap::{Bootstrap, SystemCommandRunner}; +use registry_thunderid_tooling::container::Session; +use registry_thunderid_tooling::description::{ + ClientSecretMethod, CompatibilityClient, IssuerDescription, MachineClient, OrganizationUnit, + Resource, ResourceServer, Role, SessionIdentity, +}; +use registry_thunderid_tooling::render; +use registry_thunderid_tooling::version::ThunderIdPin; + +const PORT: u16 = 18_493; +const RESOURCE: &str = "urn:registry:tooling-test:evidence"; +const CLIENT_ID: &str = "synthetic-machine-client"; +const AGENT_ID: &str = "0197aaaa-0000-7000-8000-0000000000a1"; +const COMPAT_AGENT_ID: &str = "0197aaaa-0000-7000-8000-0000000000a2"; +const SERVER_ID: &str = "0197aaaa-0000-7000-8000-0000000000b1"; +const ROLE_ID: &str = "0197aaaa-0000-7000-8000-0000000000c1"; + +fn fail(message: &str) -> ! { + eprintln!("FAIL: {message}"); + std::process::exit(1); +} + +/// A fresh ES256 client keypair, generated at run time so no key material +/// ever lives in the tree. The private half stays in owner-only files under +/// the session state; only the public JWKS is rendered into the +/// registration. +fn fresh_client_key(kid: &str) -> (PrivateJwk, String) { + let secret = p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = secret.verifying_key().to_encoded_point(false); + let encode = |bytes: &[u8]| URL_SAFE_NO_PAD.encode(bytes); + let public_jwks = format!( + "{{\"keys\":[{{\"kty\":\"EC\",\"crv\":\"P-256\",\"alg\":\"ES256\",\"kid\":\"{kid}\",\"use\":\"sig\",\"key_ops\":[\"verify\"],\"x\":\"{}\",\"y\":\"{}\"}}]}}", + encode(point.x().expect("x")), + encode(point.y().expect("y")) + ); + let jwk = serde_json::json!({ + "kty": "EC", "crv": "P-256", "alg": "ES256", "kid": kid, + "x": encode(point.x().expect("x")), + "y": encode(point.y().expect("y")), + "d": encode(&secret.to_bytes()), + }); + let private = PrivateJwk::parse(&jwk.to_string()).expect("the generated key parses"); + (private, public_jwks) +} + +fn description(state_root: &Path, public_jwks: String) -> IssuerDescription { + let mut attributes = std::collections::BTreeMap::new(); + attributes.insert( + "synthetic_tag".to_owned(), + serde_json::Value::String("fixture-agency".to_owned()), + ); + attributes.insert( + "evidence_tags".to_owned(), + serde_json::json!(["policy-a", "policy-b"]), + ); + IssuerDescription { + session: SessionIdentity { + label: "identity-integration".to_owned(), + id: "b2c3d4e5f6071829".to_owned(), + }, + port: PORT, + state_root: state_root.to_path_buf(), + organization_unit: OrganizationUnit { + id: registry_thunderid_tooling::description::DEFAULT_OU_ID.to_owned(), + handle: registry_thunderid_tooling::description::DEFAULT_OU_HANDLE.to_owned(), + name: "Default".to_owned(), + description: "Default organization unit".to_owned(), + }, + resource_servers: vec![ResourceServer { + id: SERVER_ID.to_owned(), + name: "Synthetic Evidence".to_owned(), + identifier: RESOURCE.to_owned(), + description: "Synthetic resource server for the integration driver".to_owned(), + resources: vec![Resource { + name: "Evidence".to_owned(), + handle: "evidence".to_owned(), + parent: None, + description: "Synthetic evidence operations".to_owned(), + actions: vec![registry_thunderid_tooling::description::Action { + name: "Invoke".to_owned(), + handle: "invoke".to_owned(), + description: "Invoke an evidence request".to_owned(), + }], + }], + }], + roles: vec![Role { + id: ROLE_ID.to_owned(), + name: "Synthetic Invoker".to_owned(), + description: "May invoke the synthetic resource".to_owned(), + permissions: vec![(SERVER_ID.to_owned(), vec!["evidence:invoke".to_owned()])], + assigned_agents: vec![AGENT_ID.to_owned()], + }], + machine_clients: vec![MachineClient { + agent_id: AGENT_ID.to_owned(), + name: "Synthetic Machine Client".to_owned(), + description: "Driver private_key_jwt client".to_owned(), + client_id: CLIENT_ID.to_owned(), + public_jwks, + attributes, + token_attributes: vec!["synthetic_tag".to_owned(), "evidence_tags".to_owned()], + access_token_lifetime_seconds: 300, + token_exchange: None, + }], + compatibility_clients: vec![CompatibilityClient { + agent_id: COMPAT_AGENT_ID.to_owned(), + name: "Synthetic Compatibility Client".to_owned(), + description: "Driver client_secret_post client".to_owned(), + client_id: "synthetic-compatibility-client".to_owned(), + method: ClientSecretMethod::Post, + secret_file: "secrets/compatibility-client-secret".into(), + }], + exchange_issuers: vec![], + schema_attributes: vec!["evidence_tags".to_owned(), "synthetic_tag".to_owned()], + } +} + +fn write_secret(path: &Path, bytes: &[u8]) { + use std::os::unix::fs::PermissionsExt; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("the secrets directory exists"); + } + std::fs::write(path, bytes).expect("the secret file is writable"); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .expect("the secret file is owner-only"); +} + +fn random_urlsafe(bytes: usize) -> String { + let mut buffer = vec![0_u8; bytes]; + getrandom::fill(&mut buffer).expect("the host supplies randomness"); + URL_SAFE_NO_PAD.encode(buffer) +} + +struct Live { + issuer: String, +} + +/// Bring a session up from nothing: setup, render, bootstrap, start, and +/// wait for discovery. Every step's failure names itself. +fn bring_up(state_root: &Path, public_jwks: String) -> Live { + let pin = ThunderIdPin::load().expect("the maintained pin loads"); + let description = description(state_root, public_jwks); + description + .validate() + .expect("the driver description validates"); + let session = Session { + label: &description.session.label, + id: &description.session.id, + port: description.port, + state_root: &description.state_root, + image: &pin.image, + }; + std::fs::create_dir_all(state_root.join("secrets")).expect("the state root exists"); + write_secret( + &state_root.join("secrets/compatibility-client-secret"), + random_urlsafe(32).as_bytes(), + ); + write_secret( + &state_root.join("secrets/admin-password"), + random_urlsafe(24).as_bytes(), + ); + write_secret( + &state_root.join("secrets/direct_auth_secret"), + random_urlsafe(24).as_bytes(), + ); + write_secret( + &state_root.join("secrets/throwaway-bootstrap-password"), + random_urlsafe(24).as_bytes(), + ); + + let mut runner = SystemCommandRunner; + session.prepare(&mut runner).unwrap_or_else(|error| { + fail(&format!("one-time setup did not complete: {error}")); + }); + let rendered = render::render(&description) + .unwrap_or_else(|error| fail(&format!("rendering refused the description: {error}"))); + let mut bootstrap = Bootstrap { + runner: &mut runner, + image: &pin.image, + }; + bootstrap + .apply_agent_schema( + &rendered.bootstrap_dir, + &state_root.join("secrets/throwaway-bootstrap-password"), + &[ + ( + state_root.join("database"), + "/opt/thunderid/database".into(), + ), + ( + state_root.join("certs"), + "/opt/thunderid/config/certs".into(), + ), + ( + state_root.join("secrets"), + "/opt/thunderid/config/secrets".into(), + ), + ( + state_root.join("deployment.yaml"), + "/opt/thunderid/deployment.yaml".into(), + ), + ( + rendered.bootstrap_dir.clone(), + registry_thunderid_tooling::bootstrap::SCHEMA_MOUNT_POINT.into(), + ), + ], + ) + .unwrap_or_else(|error| fail(&format!("the bootstrap one-shot failed: {error}"))); + session.start(&mut runner).unwrap_or_else(|error| { + fail(&format!( + "the serving container did not start (an unrelated port occupant is never touched): {error}" + )); + }); + wait_for_discovery(PORT); + Live { + issuer: format!("http://127.0.0.1:{PORT}"), + } +} + +fn stop(state_root: &Path) { + let pin = ThunderIdPin::load().expect("the maintained pin loads"); + let session = Session { + label: "identity-integration", + id: "b2c3d4e5f6071829", + port: PORT, + state_root, + image: &pin.image, + }; + let mut runner = SystemCommandRunner; + session + .stop(&mut runner) + .expect("the owned container stops"); +} + +fn restart(state_root: &Path) { + stop(state_root); + let pin = ThunderIdPin::load().expect("the maintained pin loads"); + let session = Session { + label: "identity-integration", + id: "b2c3d4e5f6071829", + port: PORT, + state_root, + image: &pin.image, + }; + let mut runner = SystemCommandRunner; + session + .start(&mut runner) + .expect("the session restarts on retained state"); + wait_for_discovery(PORT); +} + +fn wait_for_discovery(port: u16) { + let issuer = format!("http://127.0.0.1:{port}"); + for _ in 0..60 { + if let Ok(response) = ureq_well_known(&issuer) { + if response { + return; + } + } + std::thread::sleep(Duration::from_secs(1)); + } + fail("the issuer did not answer discovery within the bounded wait"); +} + +/// One bounded GET of the discovery document's status. This check is +/// reachability only; the functional check is the token exchange. +fn ureq_well_known(issuer: &str) -> Result { + let output = std::process::Command::new("curl") + .args([ + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "--max-time", + "5", + &format!("{issuer}/.well-known/openid-configuration"), + ]) + .output() + .map_err(|_| ())?; + let status = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + Ok(status == "200") +} + +/// The functional readiness check: the configured client obtains a correctly +/// scoped token through the shared platform provider. +fn exchange(issuer: &str, key: &PrivateJwk) -> Result { + let endpoint: url::Url = format!("{issuer}/oauth2/token") + .parse() + .expect("the token endpoint parses"); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new(endpoint, CLIENT_ID, key.clone()) + .with_audience(issuer.to_owned()) + .with_resource(RESOURCE) + .with_scopes(["evidence:invoke"]), + ) + .map_err(|error| format!("the provider refused its configuration: {error}"))?; + let token = tokio_block_on(provider.bearer_token()) + .map_err(|error| format!("the token exchange failed: {error}"))?; + // The public header accessor is the one sanctioned way to read the + // credential: strip the scheme and decode locally, never logging it. + let header = token.authorization_header_value(); + let header = header + .to_str() + .map_err(|_| "the credential is not header-safe ASCII".to_owned())?; + decode_token_claims(header.strip_prefix("Bearer ").unwrap_or(header)) +} + +fn tokio_block_on(future: F) -> F::Output { + // A tiny current-thread runtime: one exchange at a time is all this + // driver ever needs. + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a runtime is available") + .block_on(future) +} + +fn decode_token_claims(compact: &str) -> Result { + let payload = compact + .split('.') + .nth(1) + .ok_or("the credential is not a compact JWT")?; + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| "the credential payload is not base64url")?; + serde_json::from_slice(&decoded).map_err(|_| "the credential payload is not JSON".to_owned()) +} + +fn require(condition: bool, message: &str) { + if !condition { + fail(message); + } +} + +fn state_dir(base: &Path) -> PathBuf { + base.join("session-state") +} + +/// Remove this session's own container by its exact name before a case +/// recreates the state. The name encodes this driver's label and session id, +/// so nothing unrelated is ever touched; an absent container is not an error. +fn pre_clean() { + let _ = std::process::Command::new("docker") + .args(["rm", "-f", "thunderid-identity-integration-b2c3d4e5f607"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + std::thread::sleep(Duration::from_secs(6)); +} + +fn main() { + let mut case = None; + let mut state = PathBuf::from("/tmp/registry-thunderid-integration"); + let mut arguments = std::env::args().skip(1); + while let Some(argument) = arguments.next() { + match argument.as_str() { + "--case" => case = arguments.next(), + "--state" => state = PathBuf::from(arguments.next().expect("--state takes a value")), + other => fail(&format!("unknown argument {other}")), + } + } + let case = case.unwrap_or_else(|| fail("--case is required")); + std::fs::create_dir_all(&state).expect("the state base is writable"); + let state_root = state_dir(&state); + + match case.as_str() { + "A09.schema-and-restart" => { + pre_clean(); + let _ = std::fs::remove_dir_all(&state_root); + let (key, public_jwks) = fresh_client_key("integration-key-1"); + let live = bring_up(&state_root, public_jwks); + let claims = exchange(&live.issuer, &key) + .unwrap_or_else(|error| fail(&format!("the first exchange failed: {error}"))); + require( + claims.get("aud").and_then(serde_json::Value::as_str) == Some(RESOURCE), + "the token audience is not the resource identifier", + ); + require( + claims + .get("synthetic_tag") + .and_then(serde_json::Value::as_str) + == Some("fixture-agency"), + "the static agent attribute is not emitted", + ); + require( + claims.get("evidence_tags") == Some(&serde_json::json!(["policy-a", "policy-b"])), + "the static requester-tag array is not emitted intact", + ); + require( + claims.get("scope").and_then(serde_json::Value::as_str) == Some("evidence:invoke"), + "the token scope is not the granted permission", + ); + // Restart with retained state: identity, registration, and data + // all survive; no re-bootstrap happens (the marker persists). + let marker = std::fs::read_to_string(state_root.join("session.json")) + .expect("the session state is retained"); + restart(&state_root); + let after = std::fs::read_to_string(state_root.join("session.json")) + .expect("the session state is retained after restart"); + require( + marker == after, + "an ordinary restart rewrote the session state", + ); + let claims = exchange(&live.issuer, &key).unwrap_or_else(|error| { + fail(&format!("the post-restart exchange failed: {error}")) + }); + require( + claims + .get("synthetic_tag") + .and_then(serde_json::Value::as_str) + == Some("fixture-agency"), + "the machine attributes were lost across the restart", + ); + require( + claims.get("evidence_tags") == Some(&serde_json::json!(["policy-a", "policy-b"])), + "the requester-tag array was lost across the restart", + ); + stop(&state_root); + println!("PASS A09.schema-and-restart"); + } + "A10.client-key-rotation" => { + pre_clean(); + let _ = std::fs::remove_dir_all(&state_root); + let (old_key, old_public) = fresh_client_key("integration-key-1"); + let (new_key, new_public) = fresh_client_key("integration-key-2"); + let live = bring_up(&state_root, old_public.clone()); + exchange(&live.issuer, &old_key).unwrap_or_else(|error| { + fail(&format!("the old key stopped working too early: {error}")) + }); + // Rotate: register both keys during the overlap, restart, prove + // the new key works, then remove the old key and prove it no + // longer authenticates. + let combined = format!( + "{{\"keys\":[{},{}]}}", + old_public + .trim_start_matches("{\"keys\":[") + .trim_end_matches("]}"), + new_public + .trim_start_matches("{\"keys\":[") + .trim_end_matches("]}"), + ); + stop(&state_root); + rewrite_agent_jwks(&state_root, &combined); + restart(&state_root); + exchange(&live.issuer, &new_key).unwrap_or_else(|error| { + fail(&format!( + "the replacement key was refused during overlap: {error}" + )) + }); + exchange(&live.issuer, &old_key).unwrap_or_else(|error| { + fail(&format!("the old key was dropped before removal: {error}")) + }); + stop(&state_root); + rewrite_agent_jwks(&state_root, &new_public); + restart(&state_root); + exchange(&live.issuer, &new_key).unwrap_or_else(|error| { + fail(&format!( + "the replacement key stopped working after rotation: {error}" + )) + }); + require( + exchange(&live.issuer, &old_key).is_err(), + "the removed key still authenticates the client", + ); + stop(&state_root); + println!("PASS A10.client-key-rotation"); + } + "A10.client-and-role-revocation" => { + pre_clean(); + let _ = std::fs::remove_dir_all(&state_root); + let (key, public_jwks) = fresh_client_key("integration-key-1"); + let live = bring_up(&state_root, public_jwks); + exchange(&live.issuer, &key).unwrap_or_else(|error| { + fail(&format!("the pre-revocation exchange failed: {error}")) + }); + // Revoke the role assignment: the permission gate the resource + // server holds must see no scope afterwards. + stop(&state_root); + remove_role_assignment(&state_root); + restart(&state_root); + if let Ok(claims) = exchange(&live.issuer, &key) { + require( + claims.get("scope").and_then(serde_json::Value::as_str) + != Some("evidence:invoke"), + "the revoked permission is still granted in new tokens", + ); + } + stop(&state_root); + println!("PASS A10.client-and-role-revocation"); + } + "A15.restart-replay" => { + pre_clean(); + let _ = std::fs::remove_dir_all(&state_root); + let (key, public_jwks) = fresh_client_key("integration-key-1"); + let live = bring_up(&state_root, public_jwks); + // One consumed assertion, then an issuer restart on retained + // state, then the same assertion again: the replay store is + // persistent, so the replay must still be refused. + let endpoint: url::Url = format!("{}/oauth2/token", live.issuer) + .parse() + .expect("the token endpoint parses"); + let assertion = sign_assertion(&key, CLIENT_ID, &live.issuer); + let first = post_assertion(&endpoint, &assertion); + require(first, "the fresh assertion was refused"); + restart(&state_root); + let replay = post_assertion(&endpoint, &assertion); + require( + !replay, + "a consumed assertion was accepted after the issuer restart", + ); + let fresh = post_assertion(&endpoint, &sign_assertion(&key, CLIENT_ID, &live.issuer)); + require(fresh, "a fresh assertion was refused after the restart"); + stop(&state_root); + println!("PASS A15.restart-replay"); + } + other => fail(&format!("unknown case {other}")), + } +} + +/// Re-register the machine client under a new JWKS by editing the rendered +/// bootstrap document and re-running the one-shot. This is the +/// state-preserving upsert path, never a database reset. +fn rewrite_agent_jwks(state_root: &Path, public_jwks: &str) { + let path = state_root + .join(registry_thunderid_tooling::render::BOOTSTRAP_DIR) + .join("agents") + .join(format!("{AGENT_ID}.yaml")); + let text = std::fs::read_to_string(&path).expect("the agent document is readable"); + let mut document: serde_json::Value = + serde_norway::from_str(&text).expect("the agent document parses"); + document["inboundAuthConfig"][0]["config"]["certificate"]["value"] = + serde_json::Value::String(public_jwks.to_owned()); + std::fs::write( + &path, + serde_norway::to_string(&document).expect("the document serializes"), + ) + .expect("the agent document is writable"); + rerun_bootstrap(state_root); +} + +fn remove_role_assignment(state_root: &Path) { + let path = state_root + .join(registry_thunderid_tooling::render::RESOURCES_DIR) + .join("roles") + .join(format!("{ROLE_ID}.yaml")); + let text = std::fs::read_to_string(&path).expect("the role document is readable"); + let mut document: serde_json::Value = + serde_norway::from_str(&text).expect("the role document parses"); + document["assignments"] = serde_json::json!([]); + std::fs::write( + &path, + serde_norway::to_string(&document).expect("the document serializes"), + ) + .expect("the role document is writable"); +} + +fn rerun_bootstrap(state_root: &Path) { + let pin = ThunderIdPin::load().expect("the maintained pin loads"); + let mut runner = SystemCommandRunner; + let mut bootstrap = Bootstrap { + runner: &mut runner, + image: &pin.image, + }; + bootstrap + .apply_agent_schema( + &state_root.join(registry_thunderid_tooling::render::BOOTSTRAP_DIR), + &state_root.join("secrets/throwaway-bootstrap-password"), + &[ + ( + state_root.join("database"), + "/opt/thunderid/database".into(), + ), + ( + state_root.join("certs"), + "/opt/thunderid/config/certs".into(), + ), + ( + state_root.join("secrets"), + "/opt/thunderid/config/secrets".into(), + ), + ( + state_root.join("deployment.yaml"), + "/opt/thunderid/deployment.yaml".into(), + ), + ( + state_root.join(registry_thunderid_tooling::render::BOOTSTRAP_DIR), + registry_thunderid_tooling::bootstrap::SCHEMA_MOUNT_POINT.into(), + ), + ], + ) + .expect("the rotation bootstrap one-shot succeeds"); +} + +/// Sign one 60-second client assertion with the shared assertion builder. +fn sign_assertion(key: &PrivateJwk, client_id: &str, issuer: &str) -> String { + use registry_platform_authcommon::client_assertion::{ + sign_client_assertion, ClientAssertionRequest, + }; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the host clock is after the epoch") + .as_secs() as i64; + sign_client_assertion( + key, + &ClientAssertionRequest { + client_id, + audience: issuer, + lifetime_seconds: 60, + issued_at: now, + }, + ) + .expect("the assertion signs") + .as_str() + .to_owned() +} + +/// Post one assertion directly: the A15 replay check needs to resend the +/// identical assertion, which the caching provider will not do. +fn post_assertion(endpoint: &url::Url, assertion: &str) -> bool { + use std::io::Write; + use std::process::Stdio; + let body = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "client_credentials") + .append_pair("client_id", CLIENT_ID) + .append_pair( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ) + .append_pair("client_assertion", assertion) + .append_pair("resource", RESOURCE) + .append_pair("scope", "evidence:invoke") + .finish(); + let mut process = std::process::Command::new("curl") + .args([ + "--silent", + "--output", + "/dev/null", + "--write-out", + "%{http_code}", + "--max-time", + "10", + "--header", + "Content-Type: application/x-www-form-urlencoded", + "--data-binary", + "@-", + endpoint.as_str(), + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("curl is available"); + process + .stdin + .take() + .expect("request stdin") + .write_all(body.as_bytes()) + .expect("request body written"); + let output = process.wait_with_output().expect("request completes"); + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "200" +} diff --git a/crates/registry-thunderid-tooling/examples/render-fixture.rs b/crates/registry-thunderid-tooling/examples/render-fixture.rs new file mode 100644 index 0000000000..da51f7b18a --- /dev/null +++ b/crates/registry-thunderid-tooling/examples/render-fixture.rs @@ -0,0 +1,27 @@ +//! Regenerate the stored synthetic session fixture. Output is deterministic +//! for a pinned release and a fixed description; committed copies under +//! `products/identity/thunderid/fixtures/` must come from this command. +use std::path::PathBuf; + +fn main() { + let out = std::env::args() + .nth(1) + .map(PathBuf::from) + .expect("usage: render-fixture OUTPUT_DIR"); + let mut description = registry_thunderid_tooling::testing::synthetic_description(); + description.state_root = out; + std::fs::create_dir_all(description.state_root.join("secrets")).expect("secrets dir"); + std::fs::write( + description + .state_root + .join("secrets/compatibility-client-secret"), + "fixture-secret-not-a-real-credential", + ) + .expect("fixture secret"); + let _ = std::fs::remove_dir_all(description.state_root.join("resources")); + let _ = std::fs::remove_dir_all(description.state_root.join("registry-schema")); + let rendered = registry_thunderid_tooling::render::render(&description).expect("render"); + println!("resources: {}", rendered.resources_dir.display()); + println!("schema: {}", rendered.bootstrap_dir.display()); + println!("schema sha256: {}", rendered.agent_type_sha256); +} diff --git a/crates/registry-thunderid-tooling/fixtures/default-agent-type-v1.0.1.yaml b/crates/registry-thunderid-tooling/fixtures/default-agent-type-v1.0.1.yaml new file mode 100644 index 0000000000..aefbbcd29e --- /dev/null +++ b/crates/registry-thunderid-tooling/fixtures/default-agent-type-v1.0.1.yaml @@ -0,0 +1,32 @@ +resource_type: agent_type +id: 01900000-0000-7000-8000-000000000011 +name: default +ouHandle: default +schema: + modelProvider: + type: string + displayName: Model Provider + required: false + enum: + - openai + - anthropic + - gemini + - mistral + - custom + model: + type: string + displayName: Model + required: false + function: + type: string + displayName: Function + required: false + enum: + - task-automation + - rag-retrieval + - code-gen + - data-analysis + - orchestrator + - sub-agent + - assistant + - custom diff --git a/crates/registry-thunderid-tooling/src/bootstrap.rs b/crates/registry-thunderid-tooling/src/bootstrap.rs new file mode 100644 index 0000000000..62f20d03f1 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/bootstrap.rs @@ -0,0 +1,273 @@ +//! The explicit bootstrap and schema-configuration step. +//! +//! Stateful configuration provisioning, documented as such: the upstream +//! one-shot `bootstrap --defaults` command runs against owned, retained state +//! with the serving process stopped, exactly as the pinned release defines +//! it. It needs no standing admin token, no temporary system client, and no +//! HTTP admin surface, and this crate adds none of those. + +use std::path::{Path, PathBuf}; + +use crate::ToolingError; + +/// How a command is executed. Real use runs the container engine; tests run +/// fakes that assert on the exact argv and environment without starting +/// anything. +pub trait CommandRunner { + /// Run one command. `secret_environment` names environment variables + /// whose values live in owner-only files: the runner reads each file and + /// injects the value, so no secret ever sits in an argv element. + fn run( + &mut self, + program: &str, + args: &[String], + secret_environment: &[(String, PathBuf)], + ) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandOutcome { + pub success: bool, + /// Docker `ps` container IDs only. All other stdout, including setup's + /// administrator credential, is discarded by every real runner. + pub container_ids: String, +} + +/// Where the schema directory is mounted inside the one-shot container. +pub const SCHEMA_MOUNT_POINT: &str = "/mounted/registry-schema"; + +/// The bootstrap one-shot over a rendered agent-schema directory. +pub struct Bootstrap<'a> { + pub runner: &'a mut dyn CommandRunner, + pub image: &'a str, +} + +impl Bootstrap<'_> { + /// Run the upstream schema one-shot. + /// + /// `--defaults` is the whole point: without it the command re-applies the + /// entire bootstrap bundle, which would overwrite shared state. The + /// `ADMIN_PASSWORD` the upstream command demands is satisfied from a + /// fresh throwaway value in an owner-only file — never the real + /// administrator credential, never an argv element — and the command's + /// stdout is discarded without inspection because the pinned release + /// echoes the value it was given on success. + pub fn apply_agent_schema( + &mut self, + provisioning_dir: &Path, + throwaway_admin_password_file: &Path, + state_mounts: &[(PathBuf, PathBuf)], + ) -> Result<(), ToolingError> { + if !provisioning_dir.join("agent-type.yaml").is_file() { + return Err(ToolingError::InvalidState { + reason: + "the bootstrap provisioning directory does not carry the agent-schema update", + }); + } + let mut args: Vec = vec![ + "run".into(), + "--rm".into(), + "--entrypoint".into(), + "./thunderid".into(), + ]; + for (host, container) in state_mounts { + args.push("--mount".into()); + args.push(format!( + "type=bind,src={},dst={}", + host.display(), + container.display() + )); + } + args.push(self.image.to_owned()); + args.push("bootstrap".into()); + args.push("--defaults".into()); + args.push(SCHEMA_MOUNT_POINT.to_owned()); + + let outcome = self.runner.run( + "docker", + &args, + &[( + "ADMIN_PASSWORD".to_owned(), + throwaway_admin_password_file.to_path_buf(), + )], + )?; + if !outcome.success { + return Err(ToolingError::CommandFailed { + step: "the agent-schema bootstrap one-shot did not succeed; staged state is preserved for diagnosis and nothing was reset", + }); + } + Ok(()) + } +} + +/// The real command runner: executes the container engine, reads secret +/// environment values from owner-only files, and discards captured output +/// unread — upstream tooling echoes credential material on stdout, so no +/// byte of it ever reaches a log. +pub struct SystemCommandRunner; + +impl CommandRunner for SystemCommandRunner { + fn run( + &mut self, + program: &str, + args: &[String], + secret_environment: &[(String, PathBuf)], + ) -> Result { + let mut command = std::process::Command::new(program); + // `docker run` does not inherit the client's environment; `-e NAME` + // with no value forwards the client process's variable into the + // container, so each secret is injected from its owner-only file + // without ever appearing in an argv element. + let mut forwarded: Vec<&str> = Vec::new(); + let mut secrets: Vec<(String, String)> = Vec::new(); + for (name, file) in secret_environment { + let value = std::fs::read_to_string(file).map_err(|_| ToolingError::Filesystem { + reason: "a secret environment file could not be read", + })?; + forwarded.push("-e"); + forwarded.push(name); + secrets.push((name.clone(), value.trim_end_matches('\n').to_owned())); + } + let mut arguments: Vec<&str> = args.iter().map(String::as_str).collect(); + if program == "docker" { + let insertion = arguments + .iter() + .position(|argument| *argument == "run") + .map(|position| position + 2) + .unwrap_or(arguments.len()); + for (offset, flag) in forwarded.iter().enumerate() { + arguments.insert(insertion + offset, flag); + } + } + command.args(&arguments); + for (name, value) in &secrets { + command.env(name, value); + } + let output = command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|_| ToolingError::CommandFailed { + step: "the container engine could not be executed", + })?; + // Command output is not relayed: upstream tooling echoes credential + // material, and nothing here may relay it. The one exception is an + // explicitly requested diagnostic run without a supplied credential, + // which prints a bounded tail of stderr. Upstream commands can echo + // credentials, so neither stream is printed for a secret-bearing run. + if !output.status.success() + && secret_environment.is_empty() + && std::env::var_os("REGISTRY_THUNDERID_TOOLING_DIAGNOSTICS").is_some() + { + let bounded: String = String::from_utf8_lossy(&output.stderr) + .chars() + .rev() + .take(600) + .collect::>() + .into_iter() + .rev() + .collect(); + eprintln!("diagnostics: {bounded}"); + } + Ok(CommandOutcome { + success: output.status.success(), + container_ids: if args.first().is_some_and(|arg| arg == "ps") { + String::from_utf8(output.stdout).unwrap_or_default() + } else { + String::new() + }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Records every command it is asked to run and answers from a script. + struct FakeRunner { + recorded: Vec<(String, Vec, Vec)>, + success: bool, + } + + impl CommandRunner for FakeRunner { + fn run( + &mut self, + program: &str, + args: &[String], + secret_environment: &[(String, PathBuf)], + ) -> Result { + self.recorded.push(( + program.to_owned(), + args.to_vec(), + secret_environment + .iter() + .map(|(name, _)| name.clone()) + .collect(), + )); + Ok(CommandOutcome { + success: self.success, + container_ids: String::new(), + }) + } + } + + #[test] + fn the_schema_one_shot_uses_defaults_and_a_file_injected_throwaway_password() { + let mut runner = FakeRunner { + recorded: Vec::new(), + success: true, + }; + let schema = std::env::temp_dir().join("tooling-test-schema"); + std::fs::create_dir_all(&schema).expect("the schema directory exists"); + std::fs::write( + schema.join("agent-type.yaml"), + "resource_type: agent_type\n", + ) + .expect("the schema document exists"); + let password_file = std::env::temp_dir().join("tooling-test-throwaway-password"); + std::fs::write(&password_file, "throwaway-not-the-real-admin-password") + .expect("the throwaway password file exists"); + + let image = "ghcr.io/thunder-id/thunderid:1.0.1@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e"; + let mut bootstrap = Bootstrap { + runner: &mut runner, + image, + }; + bootstrap + .apply_agent_schema( + &schema, + &password_file, + &[(schema.clone(), PathBuf::from(SCHEMA_MOUNT_POINT))], + ) + .expect("the one-shot succeeds"); + + let (program, args, secrets) = &runner.recorded[0]; + assert_eq!(program, "docker"); + // `--defaults` with the mounted directory, never a bare re-bootstrap. + let position = args + .iter() + .position(|argument| argument == "bootstrap") + .expect("the bootstrap subcommand is present"); + assert_eq!(args[position + 1], "--defaults"); + assert_eq!(args[position + 2], SCHEMA_MOUNT_POINT); + // The demanded password arrives as a file-injected environment + // variable, never as an argv element. + assert_eq!(secrets, &["ADMIN_PASSWORD".to_owned()]); + assert!(!args.iter().any(|argument| argument.contains("throwaway"))); + // The entrypoint is the upstream binary, so the container runs no + // shell and no server. + assert!(args.contains(&"./thunderid".to_owned())); + assert!(!args.iter().any(|argument| argument == "serve")); + + runner.success = false; + let mut bootstrap = Bootstrap { + runner: &mut runner, + image, + }; + assert!(matches!( + bootstrap.apply_agent_schema(&schema, &password_file, &[]), + Err(ToolingError::CommandFailed { .. }) + )); + } +} diff --git a/crates/registry-thunderid-tooling/src/container.rs b/crates/registry-thunderid-tooling/src/container.rs new file mode 100644 index 0000000000..fa9530592e --- /dev/null +++ b/crates/registry-thunderid-tooling/src/container.rs @@ -0,0 +1,559 @@ +//! One development session's retained ThunderID container. +//! +//! The lifecycle rules are fixed: deterministic ownership labels plus a +//! persisted session id, numeric loopback publishing, one-time setup before +//! any resource that references the bootstrap organization is loaded, +//! retained state across stop/start, and a destructive reset that is a +//! separate, explicitly requested operation. An unrelated occupant of the +//! configured port is reported, never stopped or adopted. + +use std::path::{Path, PathBuf}; + +use crate::bootstrap::CommandRunner; +use crate::ToolingError; + +/// The ownership label every resource this session creates carries. +pub const SESSION_LABEL: &str = "registry.stack.thunderid.session"; +/// The persistent identity label: present only on this session's resources. +pub const SESSION_ID_LABEL: &str = "registry.stack.thunderid.session-id"; + +/// The retained state of one development session, persisted owner-only under +/// the description's state root. Completion markers are written only after +/// the step they name has succeeded, so a partially initialized session is +/// diagnosable rather than silently restarted. +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct SessionState { + pub setup_complete: bool, + pub schema_applied: Option, + pub container_name: Option, +} + +pub struct Session<'a> { + pub label: &'a str, + pub id: &'a str, + pub port: u16, + pub state_root: &'a Path, + pub image: &'a str, +} + +impl Session<'_> { + pub fn state_path(&self) -> PathBuf { + self.state_root.join("session.json") + } + + pub fn load_state(&self) -> Result { + let bytes = std::fs::read(self.state_path()).map_err(|_| ToolingError::InvalidState { + reason: "the session state file does not exist; run prepare before this step", + })?; + serde_json::from_slice(&bytes).map_err(|_| ToolingError::InvalidState { + reason: "the session state file is not readable", + }) + } + + fn save_state(&self, state: &SessionState) -> Result<(), ToolingError> { + use std::os::unix::fs::PermissionsExt; + // Session state is mutable bookkeeping, not a rendered document: the + // completion markers it carries are updated as steps succeed, so + // overwriting this one file is the intended behavior. + std::fs::write( + self.state_path(), + serde_json::to_vec(state).expect("state serializes"), + ) + .map_err(|_| ToolingError::Filesystem { + reason: "the session state could not be written", + })?; + std::fs::set_permissions(self.state_path(), std::fs::Permissions::from_mode(0o600)) + .map_err(|_| ToolingError::Filesystem { + reason: "the session state could not be made owner-only", + })?; + Ok(()) + } + + /// One-time upstream setup: generate a private administrator credential, + /// write the deployment configuration, and run the image's `setup.sh` + /// with persistent mounts but without the runtime resource mounts, since + /// the resources reference the organization unit setup creates. + /// + /// Setup stdout is discarded unread: the pinned release prints the + /// administrator credential there, and this crate already holds that + /// credential in its private file. + pub fn prepare(&self, runner: &mut dyn CommandRunner) -> Result<(), ToolingError> { + ensure_owner_only_dir(&self.state_root.join("secrets"))?; + ensure_regular_dir(&self.state_root.join("database"))?; + ensure_regular_dir(&self.state_root.join("certs"))?; + if self.load_state().is_ok_and(|state| state.setup_complete) { + return Ok(()); + } + let admin_password_file = self.state_root.join("secrets/admin-password"); + if !admin_password_file.exists() { + let secret = random_urlsafe(32).map_err(|_| ToolingError::Filesystem { + reason: "the administrator credential could not be generated", + })?; + write_owner_only(&admin_password_file, secret.as_bytes())?; + } + let issuer = format!("http://127.0.0.1:{}", self.port); + let deployment = deployment_yaml(&issuer); + let deployment_file = self.state_root.join("deployment.yaml"); + if !deployment_file.exists() { + write_owner_only(&deployment_file, deployment.as_bytes())?; + } + let database = self.state_root.join("database"); + // Every bind source must exist before the engine will mount it; + // setup.sh fills `certs` with the signing material it generates. + // Seed the shipped schema into a fresh session's database directory. + // The image's databases carry the table layout setup.sh's bootstrap + // writes into; an empty directory has no tables and setup cannot + // create them. The copy runs as root inside the container only to + // hand ownership to the image's non-root user, exactly as upstream + // deployment guidance does for a fresh volume. + let database_is_empty = std::fs::read_dir(&database) + .map(|entries| entries.count() == 0) + .unwrap_or(false); + if database_is_empty { + let seed: Vec = vec![ + "run".into(), + "--rm".into(), + "--user".into(), + "0:0".into(), + "--mount".into(), + format!("type=bind,src={},dst=/seed", database.display()), + "--entrypoint".into(), + "sh".to_owned(), + self.image.to_owned(), + "-c".to_owned(), + "cp -r /opt/thunderid/database/. /seed/ && chown -R 10001:10001 /seed".to_owned(), + ]; + let outcome = runner.run("docker", &seed, &[])?; + if !outcome.success { + return Err(ToolingError::CommandFailed { + step: "the shipped database schema could not be seeded into the fresh session state", + }); + } + } + + let mut args: Vec = vec!["run".into(), "--rm".into()]; + for (host, container) in self.setup_mounts() { + args.push("--mount".into()); + args.push(format!( + "type=bind,src={},dst={}", + host.display(), + container + )); + } + args.push(self.image.to_owned()); + args.push("./setup.sh".into()); + let outcome = runner.run( + "docker", + &args, + &[("ADMIN_PASSWORD".to_owned(), admin_password_file.clone())], + )?; + if !outcome.success { + // Persist the failure shape rather than retrying destructively: + // the state file deliberately still says setup is incomplete. + return Err(ToolingError::CommandFailed { + step: "upstream setup did not complete; the partial state is retained for recovery", + }); + } + let mut state = self.load_state().unwrap_or_default(); + state.setup_complete = true; + self.save_state(&state) + } + + /// Bind mounts for the setup one-shot: persistent state, but not the + /// runtime declarative resources. + fn setup_mounts(&self) -> Vec<(PathBuf, String)> { + vec![ + ( + self.state_root.join("database"), + "/opt/thunderid/database".into(), + ), + ( + self.state_root.join("certs"), + "/opt/thunderid/config/certs".into(), + ), + ( + self.state_root.join("secrets"), + "/opt/thunderid/config/secrets".into(), + ), + ( + self.state_root.join("deployment.yaml"), + "/opt/thunderid/deployment.yaml".into(), + ), + ] + } + + /// Bind mounts for normal serving: setup state plus the rendered + /// declarative resources, each as its own explicit directory so nothing + /// overbinds the image's shipped configuration tree. + fn serving_mounts(&self) -> Vec<(PathBuf, String)> { + let mut mounts = self.setup_mounts(); + mounts.push(( + self.state_root.join("resources"), + "/opt/thunderid/config/resources".into(), + )); + mounts + } + + pub fn container_name(&self) -> String { + format!( + "thunderid-{}-{}", + self.label, + &self.id[..12.min(self.id.len())] + ) + } + + /// Find only the container carrying this session's two labels and exact + /// Docker name. Docker's `ps -q` output is a container ID, never an + /// untrusted container name to pass to `rm`. + fn owned_container( + &self, + runner: &mut dyn CommandRunner, + ) -> Result, ToolingError> { + let listed = runner.run( + "docker", + &[ + "ps".into(), + "-aq".into(), + "--no-trunc".into(), + "--filter".into(), + format!("label={SESSION_LABEL}={}", self.label), + "--filter".into(), + format!("label={SESSION_ID_LABEL}={}", self.id), + "--filter".into(), + format!("name=^/{}$", self.container_name()), + ], + &[], + )?; + if !listed.success { + return Err(ToolingError::CommandFailed { + step: "the ownership query failed", + }); + } + let ids: Vec<&str> = listed.container_ids.lines().collect(); + if ids.len() > 1 + || ids + .iter() + .any(|id| id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + return Err(ToolingError::InvalidState { + reason: "the owned container listing is invalid", + }); + } + Ok(ids.first().map(|id| (*id).to_owned())) + } + + /// Start the serving container. Refuses when the port is already held by + /// anything other than this session's own container: an unrelated service + /// is reported, never stopped and never adopted. + pub fn start(&self, runner: &mut dyn CommandRunner) -> Result<(), ToolingError> { + let state = self.load_state()?; + if !state.setup_complete { + return Err(ToolingError::InvalidState { + reason: "setup has not completed for this session; run prepare first", + }); + } + let name = self.container_name(); + // A previous supervisor may have died before cleanup. Recreate only + // this exact owned container, leaving its persistent mounts intact. + if let Some(id) = self.owned_container(runner)? { + let removed = runner.run("docker", &["rm".into(), "-f".into(), id], &[])?; + if !removed.success { + return Err(ToolingError::CommandFailed { + step: "the stale owned container could not be removed", + }); + } + } + let mut args: Vec = vec![ + "run".into(), + "-d".into(), + "--name".into(), + name.clone(), + "--label".into(), + format!("{SESSION_LABEL}={}", self.label), + "--label".into(), + format!("{SESSION_ID_LABEL}={}", self.id), + "--publish".into(), + format!("127.0.0.1:{}:8090", self.port), + ]; + for (host, container) in self.serving_mounts() { + args.push("--mount".into()); + args.push(format!( + "type=bind,src={},dst={}", + host.display(), + container + )); + } + args.push(self.image.to_owned()); + args.push("./start.sh".into()); + let outcome = runner.run("docker", &args, &[])?; + if !outcome.success { + // The most common cause is an occupied port; surface it as the + // bounded, non-destructive refusal it must remain. + return Err(ToolingError::PortOccupied { + detail: format!( + "the serving container could not start on 127.0.0.1:{}; an occupant of that port is never stopped or adopted by this session", + self.port + ), + }); + } + let mut state = self.load_state()?; + state.container_name = Some(name); + self.save_state(&state) + } + + /// Stop the serving container, retaining all state. This is the normal + /// shutdown: the database, keys, registrations, and rendered resources + /// all survive for the next start. + pub fn stop(&self, runner: &mut dyn CommandRunner) -> Result<(), ToolingError> { + let Some(id) = self.owned_container(runner)? else { + return Ok(()); + }; + let outcome = runner.run("docker", &["rm".into(), "-f".into(), id], &[])?; + if !outcome.success { + return Err(ToolingError::CommandFailed { + step: "the serving container could not be stopped", + }); + } + Ok(()) + } + + /// The explicitly requested destructive reset. Nothing else in this + /// crate removes retained state. + pub fn reset(&self, runner: &mut dyn CommandRunner) -> Result<(), ToolingError> { + self.stop(runner)?; + let _ = std::fs::remove_file(self.state_path()); + Ok(()) + } +} + +/// The deployment configuration for one local session: one issuer URL fixed +/// for the session's life, plain HTTP on loopback only, the four SQLite +/// stores on the persistent bind, and the file-referenced signing material +/// setup.sh generated. +fn deployment_yaml(issuer: &str) -> String { + let host_only = issuer.trim_start_matches("http://"); + format!( + r#"server: + http_only: true + hostname: "0.0.0.0" + public_url: {issuer} + port: 8090 + security: + direct_auth_secret: "file://config/secrets/direct_auth_secret" +tls: + min_version: "1.3" + cert_file: "config/certs/server.cert" + key_file: "config/certs/server.key" +database: + config: + type: "sqlite" + sqlite: {{ path: "database/configdb.db" }} + runtime_transient: + type: "sqlite" + sqlite: {{ path: "database/runtime_transient.db" }} + entity: + type: "sqlite" + sqlite: {{ path: "database/entitydb.db" }} + runtime_persistent: + type: "sqlite" + sqlite: {{ path: "database/runtime_persistent.db" }} +crypto: + encryption: + key: "file://config/certs/crypto.key" + password_hashing: + algorithm: "PBKDF2" + keys: + - id: "default-key" + cert_file: "config/certs/signing.cert" + key_file: "config/certs/signing.key" + - id: "ecdsa-key" + cert_file: "config/certs/ecdsa-signing.cert" + key_file: "config/certs/ecdsa-signing.key" +jwt: + preferred_key_id: "default-key" +# The pinned release's serving path reads resource servers and roles from +# files only through composite stores; the mutable default ignores the +# mounted declarative resources entirely. +resource: + store: composite +role: + store: composite +identity_provider: + store: declarative +passkey: + allowed_origins: + - "{host_only}" +"# + ) +} + +pub(crate) fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), ToolingError> { + use std::os::unix::fs::PermissionsExt; + if path.exists() { + return Err(ToolingError::Filesystem { + reason: "a private session file already exists; reset the session explicitly", + }); + } + std::fs::write(path, bytes).map_err(|_| ToolingError::Filesystem { + reason: "a private session file could not be written", + })?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| { + ToolingError::Filesystem { + reason: "a private session file could not be made owner-only", + } + })?; + Ok(()) +} + +fn ensure_owner_only_dir(path: &Path) -> Result<(), ToolingError> { + use std::os::unix::fs::PermissionsExt; + ensure_regular_dir(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|_| { + ToolingError::Filesystem { + reason: "the private session directory could not be made owner-only", + } + }) +} + +fn ensure_regular_dir(path: &Path) -> Result<(), ToolingError> { + std::fs::create_dir_all(path).map_err(|_| ToolingError::Filesystem { + reason: "the private session directory could not be created", + })?; + let metadata = std::fs::symlink_metadata(path).map_err(|_| ToolingError::Filesystem { + reason: "the private session directory could not be inspected", + })?; + if !metadata.is_dir() { + return Err(ToolingError::Filesystem { + reason: "the private session directory is not an ordinary directory", + }); + } + Ok(()) +} + +pub(crate) fn random_urlsafe(bytes: usize) -> Result { + use base64::Engine as _; + let mut buffer = vec![0_u8; bytes]; + getrandom::fill(&mut buffer).map_err(|_| ())?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buffer)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bootstrap::CommandOutcome; + use std::os::unix::fs::PermissionsExt; + + #[derive(Default)] + struct Runner { + commands: Vec>, + owned_id: Option, + } + + impl CommandRunner for Runner { + fn run( + &mut self, + _: &str, + args: &[String], + _: &[(String, PathBuf)], + ) -> Result { + self.commands.push(args.to_vec()); + Ok(CommandOutcome { + success: true, + container_ids: if args.first().is_some_and(|arg| arg == "ps") { + self.owned_id + .as_ref() + .map(|id| format!("{id}\n")) + .unwrap_or_default() + } else { + String::new() + }, + }) + } + } + + #[test] + fn restart_and_stop_reclaim_only_the_exact_owned_container() { + let root = std::env::temp_dir().join(format!( + "thunderid-session-test-{}-{}", + std::process::id(), + random_urlsafe(8).unwrap() + )); + std::fs::create_dir(&root).unwrap(); + let session = Session { + label: "owned-test", + id: "0197aaaa-0000-7000-8000-0000000000a1", + port: 18091, + state_root: &root, + image: "pinned-test-image", + }; + session + .save_state(&SessionState { + setup_complete: true, + ..SessionState::default() + }) + .unwrap(); + let id = "a".repeat(64); + let mut runner = Runner { + owned_id: Some(id.clone()), + ..Runner::default() + }; + session.start(&mut runner).unwrap(); + assert_eq!(runner.commands[0][0], "ps"); + assert!(runner.commands[0].contains(&format!("label={SESSION_LABEL}=owned-test"))); + assert!(runner.commands[0].contains(&format!("label={SESSION_ID_LABEL}={}", session.id))); + assert!(runner.commands[0].contains(&format!("name=^/{}$", session.container_name()))); + assert_eq!(runner.commands[1], ["rm", "-f", &id]); + assert_eq!(runner.commands[2][0], "run"); + + runner.commands.clear(); + runner.owned_id = None; + session.stop(&mut runner).unwrap(); + assert_eq!( + runner.commands.len(), + 1, + "an absent owned container is already stopped" + ); + runner.commands.clear(); + runner.owned_id = Some(id.clone()); + session.stop(&mut runner).unwrap(); + assert_eq!(runner.commands[1], ["rm", "-f", &id]); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn retained_prepare_repairs_private_secret_directory_before_reuse() { + let root = std::env::temp_dir().join(format!( + "thunderid-secret-dir-test-{}-{}", + std::process::id(), + random_urlsafe(8).unwrap() + )); + std::fs::create_dir(&root).unwrap(); + let secrets = root.join("secrets"); + std::fs::create_dir(&secrets).unwrap(); + std::fs::set_permissions(&secrets, std::fs::Permissions::from_mode(0o755)).unwrap(); + let session = Session { + label: "owned-test", + id: "0197aaaa-0000-7000-8000-0000000000a1", + port: 18091, + state_root: &root, + image: "pinned-test-image", + }; + session + .save_state(&SessionState { + setup_complete: true, + ..SessionState::default() + }) + .unwrap(); + let mut runner = Runner::default(); + session.prepare(&mut runner).unwrap(); + assert!( + runner.commands.is_empty(), + "completed setup must not run again" + ); + assert_eq!( + std::fs::metadata(&secrets).unwrap().permissions().mode() & 0o777, + 0o700 + ); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/registry-thunderid-tooling/src/description.rs b/crates/registry-thunderid-tooling/src/description.rs new file mode 100644 index 0000000000..01558d33de --- /dev/null +++ b/crates/registry-thunderid-tooling/src/description.rs @@ -0,0 +1,619 @@ +//! The validated internal description an owning CLI supplies. +//! +//! This is an internal Rust input type, not a provisioning DSL: the owning +//! CLI keeps every authority decision — which principals, purposes, row +//! constraints, requester tags, and role assignments exist — and hands this +//! crate the closed set of upstream resources that implements them. Nothing +//! here may name a product entity, an institution, a person, or a business +//! purpose; the neutral vocabulary below is the whole of what this crate +//! understands, and review must keep it that way. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use crate::ToolingError; + +/// The pinned default agent schema's type name. Upstream v1.0.1 admits only +/// this agent type, so this crate neither invents others nor reaches for an +/// undocumented category workaround. +pub const DEFAULT_AGENT_TYPE: &str = "default"; + +/// Upstream's default organization unit, created by the bootstrap bundle. A +/// description may use it or state its own; it may not invent a third +/// spelling of either. +pub const DEFAULT_OU_HANDLE: &str = "default"; + +/// The bootstrap bundle's default organization unit id. A description using +/// this id and handle builds on the unit setup already created. +pub const DEFAULT_OU_ID: &str = "01900000-0000-7000-8000-000000000001"; + +/// The whole closed description of one issuer deployment. +#[derive(Debug, Clone)] +pub struct IssuerDescription { + /// Ownership identity for this development session. The label names the + /// container and volume; the id is a random value fixed at creation so a + /// stale label on an unrelated resource can never be mistaken for + /// ownership. + pub session: SessionIdentity, + /// The numeric loopback port the issuer's single listener is published on. + /// The issuer URL is always `http://127.0.0.1:`, and it never + /// switches between host spellings across restarts. + pub port: u16, + /// The private, caller-owned directory this session's retained state + /// lives in. This crate creates owner-only subdirectories inside it and + /// writes private files with owner-only modes; it never removes the root. + pub state_root: PathBuf, + pub organization_unit: OrganizationUnit, + pub resource_servers: Vec, + pub roles: Vec, + pub machine_clients: Vec, + pub compatibility_clients: Vec, + /// External signed-assertion issuers. Every entry has the same protected issuer mapping. + pub exchange_issuers: Vec, + /// Optional string attributes appended to the default agent schema so + /// machine clients can carry static, issuer-governed attribute values. + /// They are optional in the schema because non-Registry agents may use the + /// same default schema. + pub schema_attributes: Vec, +} + +#[derive(Debug, Clone)] +pub struct SessionIdentity { + /// A stable, filesystem- and docker-safe label unique to this project + /// session, e.g. `breg-dev-alpha`. + pub label: String, + /// A random value fixed when the session is first created. + pub id: String, +} + +#[derive(Debug, Clone)] +pub struct OrganizationUnit { + pub id: String, + pub handle: String, + pub name: String, + pub description: String, +} + +/// One resource server: the unit whose `identifier` is the exact access-token +/// audience its clients request with the RFC 8707 `resource` parameter. +#[derive(Debug, Clone)] +pub struct ResourceServer { + pub id: String, + pub name: String, + pub identifier: String, + pub description: String, + pub resources: Vec, +} + +#[derive(Debug, Clone)] +pub struct Resource { + pub name: String, + pub handle: String, + /// Upstream `parent` is a resource handle in this server. It constructs + /// multi-segment permission names without rewriting the scope string. + pub parent: Option, + pub description: String, + pub actions: Vec, +} + +#[derive(Debug, Clone)] +pub struct Action { + pub name: String, + pub handle: String, + pub description: String, +} + +/// One role: named permission strings from one resource server, assigned +/// directly to agents. There is deliberately no group or synchronization +/// layer; the owning CLI's authority declarations are the source. +#[derive(Debug, Clone)] +pub struct Role { + pub id: String, + pub name: String, + pub description: String, + /// (`resource server id`, permission strings) pairs. + pub permissions: Vec<(String, Vec)>, + /// Agent ids this role is assigned to directly. + pub assigned_agents: Vec, +} + +/// One machine client: a `private_key_jwt` agent whose own registered public +/// JWKS authenticates it. Its private key stays with the workload; this crate +/// only ever sees the public half. +#[derive(Debug, Clone)] +pub struct MachineClient { + pub agent_id: String, + pub name: String, + pub description: String, + pub client_id: String, + /// This client's own public JWKS, as JSON text. Never a pooled set. + pub public_jwks: String, + /// Static, issuer-governed attribute values emitted into tokens for this + /// client. Names must appear in `schema_attributes`. + pub attributes: BTreeMap, + /// Which of those attribute names are embedded in access tokens. + pub token_attributes: Vec, + pub access_token_lifetime_seconds: u32, + /// Enable institutional exchange alongside a narrowly authorized bootstrap grant. + pub token_exchange: Option, +} + +/// The only machine-client permission available before a task grant exists. +#[derive(Debug, Clone)] +pub struct TokenExchangeClient { + pub assertion_resource_server_id: String, + pub assertion_scope: String, +} + +/// One external authority, rendered as a native exchange-only OIDC connection. +#[derive(Debug, Clone)] +pub struct ExchangeIssuer { + pub id: String, + pub name: String, + pub issuer: String, + pub jwks_endpoint: String, +} + +/// Closed application profile. The issuer copies these signed values; consumers +/// still compare immutable client, resource, authority, scope and deadline bounds. +pub const GRANT_ATTRIBUTES: &[&str] = &[ + "registry_actor_kind", + "registry_grant_id", + "registry_grant_authority", + "registry_grant_source_issuer", + "registry_grant_client", + "registry_grant_resource", + "registry_purpose", + "registry_grant_exp", + "registry_grant_bounds", + "identity", + "registry_approver", +]; + +fn protected_attribute(name: &str) -> bool { + name.starts_with("registry_grant_") || matches!(name, "identity" | "registry_approver") +} + +/// A standard-authorization client that cannot sign: one explicitly +/// registered secret-based method. The secret itself never enters this crate; +/// `secret_file` names a caller-owned private file the renderer reads at +/// render time inside the private state directory. +#[derive(Debug, Clone)] +pub struct CompatibilityClient { + pub agent_id: String, + pub name: String, + pub description: String, + pub client_id: String, + pub method: ClientSecretMethod, + pub secret_file: PathBuf, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ClientSecretMethod { + Basic, + Post, +} + +impl ClientSecretMethod { + pub fn as_str(self) -> &'static str { + match self { + Self::Basic => "client_secret_basic", + Self::Post => "client_secret_post", + } + } +} + +const MAXIMUM_IDENTIFIER_BYTES: usize = 256; + +fn bounded(value: &str, bound: usize) -> bool { + !value.is_empty() && value.len() <= bound +} + +pub(crate) fn valid_uuid(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 36 + && bytes.iter().enumerate().all(|(index, byte)| match index { + 8 | 13 | 18 | 23 => *byte == b'-', + _ => byte.is_ascii_hexdigit(), + }) +} + +/// A permission string is the scope a client will request and a resource +/// server will see: RFC 6749 scope-token bytes, which `:` belongs to. +fn valid_permission(value: &str) -> bool { + registry_platform_httputil::valid_scope_token(value) +} + +impl IssuerDescription { + /// Validate the closed description. Every refusal here is fixed text + /// about the shape, so an owning CLI can report it before anything is + /// rendered or run. + pub fn validate(&self) -> Result<(), ToolingError> { + let refuse = |reason: &'static str| Err(ToolingError::InvalidDescription { reason }); + if self.port == 0 { + return refuse("the listener port must be non-zero"); + } + if !bounded(&self.session.label, 64) + || !self + .session + .label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return refuse( + "the session label must be 1..=64 bytes of lowercase digits and hyphens", + ); + } + if !bounded(&self.session.id, 64) { + return refuse("the session identity must be 1..=64 bytes"); + } + if !valid_uuid(&self.organization_unit.id) || !bounded(&self.organization_unit.handle, 64) { + return refuse("the organization unit must carry a UUID id and a bounded handle"); + } + if self.organization_unit.handle == DEFAULT_OU_HANDLE + && self.organization_unit.name.is_empty() + { + return refuse("the organization unit must carry a name"); + } + if self.resource_servers.is_empty() || self.resource_servers.len() > 8 { + return refuse("the description states 1..=8 resource servers"); + } + let mut identifiers = BTreeSet::new(); + let mut server_ids = BTreeSet::new(); + for server in &self.resource_servers { + if !valid_uuid(&server.id) || !server_ids.insert(server.id.clone()) { + return refuse("each resource server carries a distinct UUID id"); + } + let parsed = url::Url::parse(&server.identifier).map_err(|_| { + ToolingError::InvalidDescription { + reason: "each resource server identifier is an absolute URI", + } + })?; + if parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.scheme().is_empty() + || !identifiers.insert(server.identifier.clone()) + { + return refuse( + "each resource server identifier is a distinct absolute URI without a fragment or userinfo", + ); + } + if server.resources.is_empty() { + return refuse("each resource server states at least one resource"); + } + let mut handles = BTreeSet::new(); + for resource in &server.resources { + if !bounded(&resource.handle, 64) + || !resource + .handle + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + || !handles.insert(resource.handle.clone()) + { + return refuse("resource handles are distinct, bounded, and URL-safe"); + } + let mut action_handles = BTreeSet::new(); + for action in &resource.actions { + if !bounded(&action.handle, 64) || !action_handles.insert(action.handle.clone()) + { + return refuse("action handles are distinct and bounded"); + } + } + } + if server.resources.iter().any(|resource| { + resource + .parent + .as_ref() + .is_some_and(|parent| parent == &resource.handle || !handles.contains(parent)) + }) { + return refuse( + "resource parents must name a distinct resource handle in the same server", + ); + } + } + if self.roles.is_empty() { + return refuse("the description states at least one role"); + } + let mut issuer_ids = BTreeSet::new(); + let mut issuer_names = BTreeSet::new(); + let mut issuer_urls = BTreeSet::new(); + if self.exchange_issuers.len() > 8 { + return refuse("at most eight external exchange issuers are supported"); + } + for issuer in &self.exchange_issuers { + if !valid_uuid(&issuer.id) + || !issuer_ids.insert(&issuer.id) + || !bounded(&issuer.name, 128) + || !issuer_names.insert(&issuer.name) + || !issuer_urls.insert(&issuer.issuer) + { + return refuse( + "exchange issuer IDs, names and issuer URLs must be distinct and bounded", + ); + } + for address in [&issuer.issuer, &issuer.jwks_endpoint] { + let valid = url::Url::parse(address).is_ok_and(|url| { + url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + && url.query().is_none() + && (url.scheme() == "https" + || (url.scheme() == "http" + && matches!( + url.host_str(), + Some("127.0.0.1" | "localhost" | "host.docker.internal") + ))) + }); + if !valid { + return refuse("exchange issuer and JWKS URLs require HTTPS or an explicit development host"); + } + } + if issuer.issuer == format!("http://127.0.0.1:{}", self.port) { + return refuse("an external exchange issuer cannot name this issuer"); + } + } + let mut agent_ids = BTreeSet::new(); + for client in &self.machine_clients { + if !valid_uuid(&client.agent_id) || !agent_ids.insert(client.agent_id.clone()) { + return refuse("each machine client carries a distinct UUID agent id"); + } + if !bounded(&client.client_id, 128) { + return refuse("each machine client carries a bounded client id"); + } + let jwks: serde_json::Value = + serde_json::from_str(&client.public_jwks).map_err(|_| { + ToolingError::InvalidDescription { + reason: "each machine client's public JWKS is JSON", + } + })?; + let keys = jwks.get("keys").and_then(|keys| keys.as_array()).ok_or( + ToolingError::InvalidDescription { + reason: "each machine client's public JWKS carries a keys array", + }, + )?; + if keys.is_empty() || keys.len() > 8 { + return refuse("each machine client registers 1..=8 public keys of its own"); + } + if !(60..=86_400).contains(&client.access_token_lifetime_seconds) { + return refuse("the access token lifetime is 60..=86400 seconds"); + } + if client + .attributes + .keys() + .any(|name| protected_attribute(name)) + || client + .token_attributes + .iter() + .any(|name| protected_attribute(name)) + { + return refuse("client credentials cannot emit the protected grant namespace"); + } + if let Some(exchange) = &client.token_exchange { + if self.exchange_issuers.is_empty() + || !server_ids.contains(&exchange.assertion_resource_server_id) + || !valid_permission(&exchange.assertion_scope) + { + return refuse("exchange clients require an issuer and an exact registered bootstrap permission"); + } + let permissions: Vec<_> = self + .roles + .iter() + .filter(|role| role.assigned_agents.contains(&client.agent_id)) + .flat_map(|role| &role.permissions) + .collect(); + if permissions.is_empty() + || permissions.iter().any(|(server, scopes)| { + server != &exchange.assertion_resource_server_id + || scopes.is_empty() + || scopes + .iter() + .any(|scope| scope != &exchange.assertion_scope) + }) + { + return refuse("exchange clients may receive only their exact bootstrap permission through client credentials"); + } + } + if client.token_attributes.len() > 16 { + return refuse("at most 16 token attributes are stated"); + } + for value in client.attributes.values() { + let valid = value.as_str().is_some_and(|value| bounded(value, 512)) + || value.as_array().is_some_and(|values| { + !values.is_empty() + && values.len() <= 32 + && values.iter().all(|value| { + value.as_str().is_some_and(|value| bounded(value, 512)) + }) + }); + if !valid { + return refuse( + "static client attributes are bounded strings or nonempty bounded string arrays", + ); + } + } + } + for client in &self.compatibility_clients { + if !valid_uuid(&client.agent_id) || !agent_ids.insert(client.agent_id.clone()) { + return refuse("each compatibility client carries a distinct UUID agent id"); + } + if !bounded(&client.client_id, 128) { + return refuse("each compatibility client carries a bounded client id"); + } + } + let client_ids: BTreeSet<_> = self + .machine_clients + .iter() + .map(|client| client.client_id.as_str()) + .chain( + self.compatibility_clients + .iter() + .map(|client| client.client_id.as_str()), + ) + .collect(); + if client_ids.len() != self.machine_clients.len() + self.compatibility_clients.len() { + return refuse("client ids are distinct across every registration"); + } + let mut role_ids = BTreeSet::new(); + for role in &self.roles { + if !valid_uuid(&role.id) || !role_ids.insert(role.id.clone()) { + return refuse("each role carries a distinct UUID id"); + } + if role.permissions.is_empty() { + return refuse("each role states at least one permission entry"); + } + for (server_id, permissions) in &role.permissions { + if !server_ids.contains(server_id) { + return refuse("each role permission names a stated resource server"); + } + if permissions.is_empty() + || permissions.len() > 32 + || permissions + .iter() + .any(|permission| !valid_permission(permission)) + { + return refuse("role permissions are 1..=32 RFC 6749 scope-tokens"); + } + } + for agent in &role.assigned_agents { + if !agent_ids.contains(agent) { + return refuse("each role assignment names a stated agent"); + } + } + } + let mut attribute_names = BTreeSet::new(); + for name in &self.schema_attributes { + if protected_attribute(name) { + return refuse("static agent schemas cannot declare protected grant attributes"); + } + if !bounded(name, 128) + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || !attribute_names.insert(name.clone()) + { + return refuse("schema attributes are distinct, bounded snake_case names"); + } + let mut array = None; + for value in self + .machine_clients + .iter() + .filter_map(|client| client.attributes.get(name)) + { + let current = value.is_array(); + if array.replace(current).is_some_and(|prior| prior != current) { + return refuse("each static attribute has one consistent schema type"); + } + } + } + for client in &self.machine_clients { + for name in client.attributes.keys() { + if !attribute_names.contains(name) { + return refuse("every client attribute is a stated schema attribute"); + } + } + for name in &client.token_attributes { + if !client.attributes.contains_key(name) { + return refuse("every token attribute is a stated client attribute"); + } + } + } + if self.session.label.len() > MAXIMUM_IDENTIFIER_BYTES { + return refuse("unreachable bound"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(super) fn exchange_description() -> IssuerDescription { + let mut description = crate::testing::synthetic_description(); + description.exchange_issuers.push(ExchangeIssuer { + id: "0197aaaa-0000-7000-8000-0000000000d1".into(), + name: "Synthetic Authority".into(), + issuer: "https://authority.example".into(), + jwks_endpoint: "https://authority.example/jwks".into(), + }); + description.machine_clients[0].token_exchange = Some(TokenExchangeClient { + assertion_resource_server_id: description.resource_servers[0].id.clone(), + assertion_scope: "evidence:invoke".into(), + }); + description + } + + #[test] + fn static_claim_paths_cannot_manufacture_grant_authority() { + for name in GRANT_ATTRIBUTES + .iter() + .copied() + .filter(|name| protected_attribute(name)) + .chain(["registry_grant_future_bound"]) + { + let mut description = exchange_description(); + description.schema_attributes.push(name.into()); + assert!(description.validate().is_err(), "protected schema accepted"); + let mut description = exchange_description(); + description.machine_clients[0] + .attributes + .insert(name.into(), "synthetic".into()); + assert!( + description.validate().is_err(), + "protected client attribute accepted" + ); + let mut description = exchange_description(); + description.machine_clients[0] + .token_attributes + .push(name.into()); + assert!( + description.validate().is_err(), + "protected client token attribute accepted" + ); + } + } + + #[test] + fn every_assigned_role_is_limited_to_the_exact_bootstrap_permission() { + let mut description = exchange_description(); + assert!(description.validate().is_ok()); + description.roles[0].permissions[0] + .1 + .push("evidence:write".into()); + assert!(description.validate().is_err()); + let mut description = exchange_description(); + let mut extra_role = description.roles[0].clone(); + extra_role.id = "0197aaaa-0000-7000-8000-0000000000c2".into(); + extra_role.permissions[0].1 = vec!["evidence:write".into()]; + description.roles.push(extra_role); + assert!(description.validate().is_err()); + let mut description = exchange_description(); + description.roles[0].assigned_agents.clear(); + assert!(description.validate().is_err()); + } + + #[test] + fn exchange_requires_distinct_external_issuers_and_explicit_jwks_trust() { + let mut description = exchange_description(); + description.exchange_issuers.clear(); + assert!(description.validate().is_err()); + let mut description = exchange_description(); + let mut duplicate = description.exchange_issuers[0].clone(); + duplicate.id = "0197aaaa-0000-7000-8000-0000000000d2".into(); + duplicate.name = "Other name".into(); + description.exchange_issuers.push(duplicate); + assert!(description.validate().is_err()); + for address in [ + "http://authority.example/jwks", + "file:///tmp/jwks", + "https://user:secret@authority.example/jwks", + "https://authority.example/jwks#fragment", + ] { + let mut description = exchange_description(); + description.exchange_issuers[0].jwks_endpoint = address.into(); + assert!(description.validate().is_err()); + } + } +} diff --git a/crates/registry-thunderid-tooling/src/issuer.rs b/crates/registry-thunderid-tooling/src/issuer.rs new file mode 100644 index 0000000000..7fa6a08c8f --- /dev/null +++ b/crates/registry-thunderid-tooling/src/issuer.rs @@ -0,0 +1,70 @@ +//! What an owning CLI reads back about the issuer it now has. + +use crate::description::IssuerDescription; + +/// The public, non-secret projection of one running session's issuer: the +/// endpoints a client needs, the image pin that serves them, and the public +/// registration information for each client the description stated. No +/// private key, secret, or token is ever part of this structure. +#[derive(Debug, Clone)] +pub struct IssuerEndpoints { + pub issuer: String, + pub token_endpoint: String, + pub jwks_uri: String, + pub image: String, + pub version: String, + pub public_clients: Vec, +} + +#[derive(Debug, Clone)] +pub struct PublicClient { + pub client_id: String, + /// The RFC 8707 resource indicators this client may name, derived from + /// the roles it was assigned. Informational for the owning CLI; the + /// issuer and the resource servers enforce the real bounds. + pub resources: Vec, +} + +impl IssuerEndpoints { + /// Derive the endpoints from a validated description and the pin. The + /// issuer URL is fixed for the session's life and never switches host + /// spellings between restarts. + pub fn from_description( + description: &IssuerDescription, + pin: &crate::version::ThunderIdPin, + ) -> Result { + description.validate()?; + let issuer = format!("http://127.0.0.1:{}", description.port); + Ok(Self { + token_endpoint: format!("{issuer}/oauth2/token"), + jwks_uri: format!("{issuer}/oauth2/jwks"), + issuer, + image: pin.image.clone(), + version: pin.version.clone(), + public_clients: description + .machine_clients + .iter() + .map(|client| PublicClient { + client_id: client.client_id.clone(), + resources: description + .roles + .iter() + .filter(|role| { + role.assigned_agents + .iter() + .any(|agent| agent == &client.agent_id) + }) + .flat_map(|role| &role.permissions) + .filter_map(|(server_id, _)| { + description + .resource_servers + .iter() + .find(|server| &server.id == server_id) + .map(|server| server.identifier.clone()) + }) + .collect(), + }) + .collect(), + }) + } +} diff --git a/crates/registry-thunderid-tooling/src/lib.rs b/crates/registry-thunderid-tooling/src/lib.rs new file mode 100644 index 0000000000..987c0a9888 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/lib.rs @@ -0,0 +1,67 @@ +//! Tooling-only support for pinned ThunderID and its reviewed native extensions. +//! +//! This crate exists so adopter CLIs and integration tests can stand up one +//! development session's ThunderID container, render the pinned release's +//! native declarative resources, perform the upstream bootstrap one-shot, and +//! read back the endpoints and public registration information — without any +//! runtime product gaining a dependency on an issuer implementation. +//! +//! It is deliberately not a general identity platform: +//! +//! - It opens no listener and ships no binary; only adopter CLIs and +//! integration tests depend on it. The BREG, Evidence, Relay, and OID4VCI +//! runtime crates must not. +//! - It renders what an owning CLI's validated [`description::IssuerDescription`] +//! states. It knows no product entity names, institutions, people, or +//! business purposes, and it must never grow any. +//! - The upstream YAML it writes is the handoff to an externally operated +//! ThunderID: there is no controller, reconciler, standing admin credential, +//! or Registry-owned issuer service here. +//! - One instance owns one development session's container. It never stops, +//! removes, or claims a container or volume it did not create, and its +//! destructive reset is a separate, explicitly requested operation. +//! +//! The upstream version and image this crate will use live in +//! [`thunderid-version.json`] beside this crate's manifest, and nowhere else; +//! tooling, examples, and CI all read that one pin. + +pub mod bootstrap; +pub mod citizen; +pub mod container; +pub mod description; +pub mod issuer; +pub mod local; +mod local_session; +pub mod render; +pub mod version; + +/// Test-only fixture support. Never used by, or reachable from, any runtime +/// product; the integration launcher and this crate's own tests are its +/// consumers. +#[doc(hidden)] +pub mod testing; + +/// Every way this crate refuses. Fixed text only: no secret, key, assertion, +/// token, or upstream error body ever travels in one of these. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ToolingError { + #[error("the ThunderID pin is invalid: {reason}")] + InvalidPin { reason: &'static str }, + #[error("the issuer description is invalid: {reason}")] + InvalidDescription { reason: &'static str }, + #[error("the rendered upstream resources are invalid: {reason}")] + InvalidRender { reason: &'static str }, + #[error("the development session state is unusable: {reason}")] + InvalidState { reason: &'static str }, + #[error("a command this session owns did not succeed: {step}")] + CommandFailed { step: &'static str }, + #[error("the issuer did not become reachable: {step}")] + Unreachable { step: &'static str }, + #[error("the functional token check failed: {reason}")] + TokenCheck { reason: &'static str }, + #[error("an unexpected port occupant refused this session: {detail}")] + PortOccupied { detail: String }, + #[error("the filesystem refused this session: {reason}")] + Filesystem { reason: &'static str }, +} diff --git a/crates/registry-thunderid-tooling/src/local.rs b/crates/registry-thunderid-tooling/src/local.rs new file mode 100644 index 0000000000..f230561240 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/local.rs @@ -0,0 +1,332 @@ +//! Common construction for local adopter sessions. Product CLIs own the +//! declared clients and scopes, credential files, and interpretation of claims. +pub use crate::local_session::{start, stop}; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::description::{ + Action, IssuerDescription, MachineClient, OrganizationUnit, Resource, ResourceServer, Role, + SessionIdentity, DEFAULT_OU_HANDLE, DEFAULT_OU_ID, +}; +use crate::ToolingError; + +#[derive(Debug, Clone)] +pub struct LocalClient { + pub client_id: String, + pub public_jwks: String, + pub claims: BTreeMap, + pub scopes: Vec, + /// Only a local teaching fixture may mark a machine-issued token human. + /// This never changes the consuming runtime's human-session policy. + pub allow_human_fixture: bool, +} + +#[derive(Debug, Clone)] +pub struct TypedLocalClient { + pub client_id: String, + pub public_jwks: String, + pub claims: BTreeMap, + pub scopes: Vec, + pub allow_human_fixture: bool, +} + +/// Stable opaque native entity ID, matching the original BREG dev renderer. +/// It does not carry a timestamp and must not be decoded for identity facts. +pub fn agent_id(session_id: &str, client_id: &str) -> String { + derived_uuid(&format!("{session_id}:agent:{client_id}")) +} + +fn derived_uuid(seed: &str) -> String { + let digest = Sha256::digest(seed.as_bytes()); + let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + let mut result = String::with_capacity(36); + for (index, character) in hex.chars().take(32).enumerate() { + result.push(match index { + 12 => '7', + 16 => '8', + _ => character, + }); + if matches!(index, 7 | 11 | 15 | 19) { + result.push('-'); + } + } + result +} + +/// Render one audience and the exact declared scope trees. Colon-delimited +/// handles must be directly representable by the pinned upstream grammar; +/// no permission is renamed or approximated. Institutional grants use the +/// separate exchange description and never ride static local attributes. +pub fn local_description( + session: SessionIdentity, + port: u16, + state_root: PathBuf, + audience: String, + clients: Vec, +) -> Result { + typed_local_description( + session, + port, + state_root, + audience, + clients + .into_iter() + .map(|client| TypedLocalClient { + client_id: client.client_id, + public_jwks: client.public_jwks, + claims: client + .claims + .into_iter() + .map(|(name, value)| (name, Value::String(value))) + .collect(), + scopes: client.scopes, + allow_human_fixture: client.allow_human_fixture, + }) + .collect(), + ) +} + +pub fn typed_local_description( + session: SessionIdentity, + port: u16, + state_root: PathBuf, + audience: String, + clients: Vec, +) -> Result { + let refuse = |reason| ToolingError::InvalidDescription { reason }; + let server_id = derived_uuid(&format!("{}:server", session.id)); + let mut resources: BTreeMap, Resource> = BTreeMap::new(); + let mut attributes = BTreeSet::new(); + let mut machine_clients = Vec::new(); + let mut roles = Vec::new(); + for client in clients { + if client + .claims + .get("registry_actor_kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == "human") + && !client.allow_human_fixture + { + return Err(refuse( + "human actor markers require an explicit local teaching fixture", + )); + } + for scope in &client.scopes { + let segments: Vec<_> = scope.split(':').collect(); + if segments.len() < 2 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }) + { + return Err(refuse( + "local scopes must be exact colon-delimited upstream handles", + )); + } + let chain: Vec = segments[..segments.len() - 1] + .iter() + .map(|part| (*part).into()) + .collect(); + let action = segments[segments.len() - 1]; + for depth in 1..=chain.len() { + let prefix = chain[..depth].to_vec(); + resources.entry(prefix.clone()).or_insert_with(|| Resource { + name: chain[depth - 1].clone(), + handle: chain[depth - 1].clone(), + parent: (depth > 1).then(|| chain[depth - 2].clone()), + description: format!("local resource {}", prefix.join(":")), + actions: vec![], + }); + } + let leaf = resources.get_mut(&chain).expect("inserted resource chain"); + if !leaf + .actions + .iter() + .any(|existing| existing.handle == action) + { + leaf.actions.push(Action { + name: action.into(), + handle: action.into(), + description: format!("local permission {scope}"), + }); + } + } + let native_id = agent_id(&session.id, &client.client_id); + let role_id = derived_uuid(&format!("{}:role:{}", session.id, client.client_id)); + attributes.extend(client.claims.keys().cloned()); + roles.push(Role { + id: role_id, + name: format!("Local {}", client.client_id), + description: "Explicit local client permissions".into(), + permissions: vec![(server_id.clone(), client.scopes)], + assigned_agents: vec![native_id.clone()], + }); + machine_clients.push(MachineClient { + agent_id: native_id, + name: format!("Local {}", client.client_id), + description: "Local teaching client".into(), + client_id: client.client_id, + public_jwks: client.public_jwks, + token_attributes: client.claims.keys().cloned().collect(), + attributes: client.claims, + access_token_lifetime_seconds: 300, + token_exchange: None, + }); + } + let description = IssuerDescription { + session, + port, + state_root, + organization_unit: OrganizationUnit { + id: DEFAULT_OU_ID.into(), + handle: DEFAULT_OU_HANDLE.into(), + name: "Default".into(), + description: "Default organization unit".into(), + }, + resource_servers: vec![ResourceServer { + id: server_id, + name: "Local development".into(), + identifier: audience, + description: "Local development audience".into(), + resources: resources.into_values().collect(), + }], + roles, + machine_clients, + compatibility_clients: vec![], + exchange_issuers: vec![], + schema_attributes: attributes.into_iter().collect(), + }; + description.validate()?; + Ok(description) +} + +#[cfg(test)] +mod tests { + use super::*; + fn client() -> LocalClient { + LocalClient { + client_id: "staff".into(), + public_jwks: crate::testing::SYNTHETIC_CLIENT_PUBLIC_JWKS.into(), + claims: BTreeMap::from([ + ("registry_actor_kind".into(), "service".into()), + ("registry_purpose".into(), "synthetic".into()), + ]), + scopes: vec!["casework:grants:assert".into(), "casework:staff".into()], + allow_human_fixture: false, + } + } + fn build(client: LocalClient) -> Result { + local_description( + SessionIdentity { + label: "local-builder-test".into(), + id: "synthetic-session".into(), + }, + 8091, + PathBuf::from("/tmp/local-builder-test"), + "urn:synthetic:local".into(), + vec![client], + ) + } + #[test] + fn native_principal_and_nested_scopes_remain_exact() { + let description = build(client()).unwrap(); + assert_eq!( + description.machine_clients[0].agent_id, + agent_id("synthetic-session", "staff") + ); + assert_eq!( + description.roles[0].permissions[0].1, + ["casework:grants:assert", "casework:staff"] + ); + let resource = description.resource_servers[0] + .resources + .iter() + .find(|r| r.handle == "grants") + .unwrap(); + assert_eq!(resource.parent.as_deref(), Some("casework")); + assert_eq!(resource.actions[0].handle, "assert"); + assert_ne!( + agent_id("synthetic-session", "staff"), + agent_id("other-session", "staff") + ); + assert_ne!( + agent_id("synthetic-session", "staff"), + agent_id("synthetic-session", "other-client") + ); + } + #[test] + fn teaching_human_requires_explicit_flag_and_never_admits_grants() { + let mut client = client(); + client + .claims + .insert("registry_actor_kind".into(), "human".into()); + assert!(build(client.clone()).is_err()); + client.allow_human_fixture = true; + assert!(build(client.clone()).is_ok()); + client + .claims + .insert("registry_grant_id".into(), "synthetic-grant".into()); + assert!(build(client).is_err()); + } + #[test] + fn unrepresentable_scope_and_ambiguous_resource_handles_fail_closed() { + let mut client = client(); + client.scopes = vec!["unstructured".into()]; + assert!(build(client.clone()).is_err()); + client.scopes = vec!["a:records:get".into(), "b:records:get".into()]; + assert!(build(client).is_err()); + } + + #[test] + fn typed_local_description_preserves_bounded_string_arrays() { + let root = std::env::temp_dir().join(format!( + "registry-thunderid-local-array-{}", + crate::container::random_urlsafe(12).unwrap() + )); + let description = typed_local_description( + SessionIdentity { + label: "local-array-test".into(), + id: "synthetic-array-session".into(), + }, + 8091, + root.clone(), + "urn:synthetic:local".into(), + vec![TypedLocalClient { + client_id: "staff".into(), + public_jwks: crate::testing::SYNTHETIC_CLIENT_PUBLIC_JWKS.into(), + claims: BTreeMap::from([ + ("registry_actor_kind".into(), serde_json::json!("service")), + ( + "evidence_tags".into(), + serde_json::json!(["policy-a", "policy-b"]), + ), + ]), + scopes: vec!["evidence:invoke".into()], + allow_human_fixture: false, + }], + ) + .unwrap(); + let agent_type = crate::render::agent_type_document(&description).unwrap(); + let schema: serde_json::Value = serde_norway::from_str(&agent_type).unwrap(); + assert_eq!(schema["schema"]["evidence_tags"]["type"], "array"); + assert_eq!(schema["schema"]["evidence_tags"]["items"]["type"], "string"); + let rendered = crate::render::render(&description).unwrap(); + let agent = std::fs::read_to_string(rendered.bootstrap_dir.join(format!( + "agents/{}.yaml", + description.machine_clients[0].agent_id + ))) + .unwrap(); + let agent: serde_json::Value = serde_norway::from_str(&agent).unwrap(); + assert_eq!( + agent["attributes"]["evidence_tags"], + serde_json::json!(["policy-a", "policy-b"]) + ); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/registry-thunderid-tooling/src/local_session.rs b/crates/registry-thunderid-tooling/src/local_session.rs new file mode 100644 index 0000000000..5423d85f79 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/local_session.rs @@ -0,0 +1,411 @@ +//! Bounded local issuer startup shared by adopter CLIs. Only public discovery +//! and verification keys leave this module; captured command output does not. +use std::collections::BTreeSet; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +use crate::bootstrap::{Bootstrap, CommandOutcome, CommandRunner, SCHEMA_MOUNT_POINT}; +use crate::container::{random_urlsafe, write_owner_only, Session}; +use crate::ToolingError; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(120); +const READY_TIMEOUT: Duration = Duration::from_secs(120); +const MAX_PUBLIC_RESPONSE: usize = 256 * 1024; + +fn cancelled_error() -> ToolingError { + ToolingError::CommandFailed { + step: "local issuer startup was cancelled", + } +} + +struct DockerRunner<'a> { + docker: &'a Path, + cancelled: &'a mut dyn FnMut() -> bool, +} +impl CommandRunner for DockerRunner<'_> { + fn run( + &mut self, + _: &str, + args: &[String], + secret_environment: &[(String, PathBuf)], + ) -> Result { + if (self.cancelled)() { + return Err(cancelled_error()); + } + let mut command = Command::new(self.docker); + let mut flags = Vec::new(); + for (name, file) in secret_environment { + let metadata = + std::fs::symlink_metadata(file).map_err(|_| ToolingError::Filesystem { + reason: "an issuer secret file cannot be inspected", + })?; + if !metadata.is_file() + || metadata.permissions().mode() & 0o077 != 0 + || metadata.len() > 16 * 1024 + { + return Err(ToolingError::Filesystem { + reason: "issuer secret files must be bounded owner-only ordinary files", + }); + } + let value = std::fs::read_to_string(file).map_err(|_| ToolingError::Filesystem { + reason: "an issuer secret file cannot be read", + })?; + command.env(name, value.trim_end_matches('\n')); + flags.extend(["-e".to_owned(), name.clone()]); + } + let one_shot = + args.first().is_some_and(|arg| arg == "run") && args.iter().any(|arg| arg == "--rm"); + let command_name = if one_shot { + let name = format!( + "thunderid-local-command-{}", + random_urlsafe(16).map_err(|_| ToolingError::CommandFailed { + step: "local command identity generation failed" + })? + ); + flags.extend(["--name".into(), name.clone()]); + Some(name) + } else { + None + }; + if args.first().is_some_and(|arg| arg == "run") { + command.arg("run").args(&flags).args(&args[1..]); + } else { + command.args(args); + } + let ids_only = args.first().is_some_and(|arg| arg == "ps"); + let diagnostics = secret_environment.is_empty() + && std::env::var_os("REGISTRY_THUNDERID_TOOLING_DIAGNOSTICS").is_some(); + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(if diagnostics { + Stdio::piped() + } else { + Stdio::null() + }) + .spawn() + .map_err(|_| ToolingError::CommandFailed { + step: "the resolved container engine could not be executed", + })?; + let mut stdout = child.stdout.take().expect("stdout is piped"); + let stderr = child.stderr.take(); + let drain = std::thread::spawn(move || { + let mut captured = Vec::new(); + let mut buffer = [0u8; 8192]; + while let Ok(n) = stdout.read(&mut buffer) { + if n == 0 { + break; + } + if ids_only && captured.len() + n <= 4096 { + captured.extend_from_slice(&buffer[..n]); + } else if ids_only { + captured.clear(); + return Vec::new(); + } + } + captured + }); + let diagnostic_drain = stderr.map(|mut stderr| { + std::thread::spawn(move || { + let mut captured = Vec::new(); + let mut buffer = [0u8; 2048]; + while let Ok(n) = stderr.read(&mut buffer) { + if n == 0 { + break; + } + captured.extend_from_slice(&buffer[..n]); + if captured.len() > 4096 { + captured.drain(..captured.len() - 4096); + } + } + captured + }) + }); + let deadline = Instant::now() + COMMAND_TIMEOUT; + let result = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status.success()), + Ok(None) => {} + Err(_) => { + break Err(ToolingError::CommandFailed { + step: "the container command status could not be read", + }) + } + } + if (self.cancelled)() { + break Err(cancelled_error()); + } + if Instant::now() >= deadline { + break Err(ToolingError::CommandFailed { + step: "the container command exceeded its time limit", + }); + } + std::thread::sleep(Duration::from_millis(100)); + }; + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + // A one-shot has an unguessable name assigned by this exact run. + // No other container is selected, and all session mounts survive. + if let Some(name) = command_name { + let mut cleanup = DockerRunner { + docker: self.docker, + cancelled: &mut || false, + }; + let _ = cleanup.run("docker", &["rm".into(), "-f".into(), name], &[]); + } + } + let bytes = drain.join().unwrap_or_default(); + if diagnostics && !result.as_ref().is_ok_and(|success| *success) { + let diagnostic = diagnostic_drain + .and_then(|drain| drain.join().ok()) + .unwrap_or_default(); + let bounded: String = String::from_utf8_lossy(&diagnostic) + .chars() + .rev() + .take(600) + .collect::>() + .into_iter() + .rev() + .collect(); + eprintln!("issuer diagnostics: {bounded}"); + } else if let Some(drain) = diagnostic_drain { + let _ = drain.join(); + } + Ok(CommandOutcome { + success: result?, + container_ids: String::from_utf8(bytes).unwrap_or_default(), + }) + } +} + +/// Start already-rendered local state and return a bounded public RS256 JWKS +/// snapshot. Every restart reprovisions the authored registrations, preserves +/// retained databases/keys, and reads discovery from the exact numeric loopback +/// origin. A true cancellation callback aborts and cleans up only this session. +pub fn start( + session: &Session<'_>, + docker: &Path, + cancelled: &mut dyn FnMut() -> bool, +) -> Result { + let result = start_inner(session, docker, cancelled); + if result.is_err() { + let _ = stop(session, docker); + } + result +} + +fn start_inner( + session: &Session<'_>, + docker: &Path, + cancelled: &mut dyn FnMut() -> bool, +) -> Result { + let mut runner = DockerRunner { docker, cancelled }; + session.prepare(&mut runner)?; + let password = session + .state_root + .join("secrets/throwaway-bootstrap-password"); + if !password.exists() { + write_owner_only( + &password, + random_urlsafe(32) + .map_err(|_| ToolingError::Filesystem { + reason: "bootstrap credential generation failed", + })? + .as_bytes(), + )?; + } + let bootstrap_dir = session.state_root.join(crate::render::BOOTSTRAP_DIR); + Bootstrap { + runner: &mut runner, + image: session.image, + } + .apply_agent_schema( + &bootstrap_dir, + &password, + &[ + ( + session.state_root.join("database"), + "/opt/thunderid/database".into(), + ), + ( + session.state_root.join("certs"), + "/opt/thunderid/config/certs".into(), + ), + ( + session.state_root.join("secrets"), + "/opt/thunderid/config/secrets".into(), + ), + ( + session.state_root.join("deployment.yaml"), + "/opt/thunderid/deployment.yaml".into(), + ), + (bootstrap_dir.clone(), SCHEMA_MOUNT_POINT.into()), + ( + session.state_root.join("resources"), + "/opt/thunderid/config/resources".into(), + ), + ], + )?; + session.start(&mut runner)?; + let issuer = format!("http://127.0.0.1:{}", session.port); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| ToolingError::Unreachable { + step: "local HTTP runtime could not start", + })?; + let http = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(5)) + .connect_timeout(Duration::from_secs(2)) + .build() + .map_err(|_| ToolingError::Unreachable { + step: "local HTTP client could not start", + })?; + let deadline = Instant::now() + READY_TIMEOUT; + loop { + if (runner.cancelled)() { + return Err(cancelled_error()); + } + if let Ok(discovery) = runtime.block_on(public_json( + &http, + &format!("{issuer}/.well-known/openid-configuration"), + )) { + return runtime.block_on(snapshot(&http, &issuer, &discovery)); + } + if Instant::now() >= deadline { + return Err(ToolingError::Unreachable { + step: "the issuer did not answer discovery within 120 seconds", + }); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +/// Stop only the container whose two ownership labels and exact name match +/// this session. Missing state/containers are already stopped; retained files +/// are never removed. +pub fn stop(session: &Session<'_>, docker: &Path) -> Result<(), ToolingError> { + session.stop(&mut DockerRunner { + docker, + cancelled: &mut || false, + }) +} + +async fn public_json(http: &reqwest::Client, url: &str) -> Result { + let refusal = || ToolingError::Unreachable { + step: "the local public issuer endpoint did not return bounded JSON", + }; + let mut response = http.get(url).send().await.map_err(|_| refusal())?; + if response.status() != reqwest::StatusCode::OK { + return Err(refusal()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| refusal())? { + if bytes.len() + chunk.len() > MAX_PUBLIC_RESPONSE { + return Err(refusal()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| refusal()) +} + +async fn snapshot( + http: &reqwest::Client, + issuer: &str, + discovery: &Value, +) -> Result { + let jwks_uri = format!("{issuer}/oauth2/jwks"); + if discovery["issuer"] != issuer || discovery["jwks_uri"] != jwks_uri { + return Err(ToolingError::Unreachable { + step: "discovery did not bind the exact local issuer and JWKS endpoint", + }); + } + let jwks = public_json(http, &jwks_uri).await?; + public_rsa_keys(&jwks) +} + +fn public_rsa_keys(jwks: &Value) -> Result { + let refusal = || ToolingError::Unreachable { + step: "the issuer JWKS has no bounded distinct RS256 verification key set", + }; + let entries = jwks["keys"].as_array().ok_or_else(refusal)?; + if entries.len() > 32 { + return Err(refusal()); + } + let mut ids = BTreeSet::new(); + let mut keys = Vec::new(); + for key in entries.iter().filter(|key| key["alg"] == "RS256") { + let kid = key["kid"] + .as_str() + .filter(|kid| !kid.is_empty() && kid.len() <= 256) + .ok_or_else(refusal)?; + if !ids.insert(kid) || key["kty"] != "RSA" || key.get("d").is_some() { + return Err(refusal()); + } + let public = + json!({"kty":"RSA","kid":kid,"alg":"RS256","use":"sig","n":key["n"],"e":key["e"]}); + registry_platform_crypto::PublicJwk::parse(&public.to_string()).map_err(|_| refusal())?; + keys.push(public); + } + if keys.is_empty() || keys.len() > 16 { + return Err(refusal()); + } + Ok(json!({"keys":keys})) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn a_cancelled_runner_never_executes_the_engine() { + let mut runner = DockerRunner { + docker: Path::new("/does-not-exist"), + cancelled: &mut || true, + }; + assert!(matches!( + runner.run("docker", &["ps".into()], &[]), + Err(ToolingError::CommandFailed { + step: "local issuer startup was cancelled" + }) + )); + } + #[test] + fn secret_permissions_fail_before_starting_the_engine() { + let path = std::env::temp_dir().join(format!( + "issuer-secret-permissions-{}", + random_urlsafe(12).unwrap() + )); + std::fs::write(&path, "synthetic-only").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let mut runner = DockerRunner { + docker: Path::new("/does-not-exist"), + cancelled: &mut || false, + }; + assert!(matches!( + runner.run( + "docker", + &["run".into()], + &[("ADMIN_PASSWORD".into(), path.clone())] + ), + Err(ToolingError::Filesystem { .. }) + )); + std::fs::remove_file(path).unwrap(); + } + #[test] + fn jwks_without_the_pinned_signing_algorithm_is_refused() { + assert!(public_rsa_keys(&json!({"keys":[]})).is_err()); + assert!(public_rsa_keys(&json!({"keys":[{"alg":"ES256"}]})).is_err()); + assert!(public_rsa_keys( + &json!({"keys":[{"alg":"RS256","kid":"one","kty":"RSA","d":"forbidden"}]}) + ) + .is_err()); + } +} diff --git a/crates/registry-thunderid-tooling/src/render.rs b/crates/registry-thunderid-tooling/src/render.rs new file mode 100644 index 0000000000..600e000635 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/render.rs @@ -0,0 +1,592 @@ +//! Rendering of the pinned release's native declarative resources. +//! +//! The output is upstream YAML in upstream's own schema, kept inspectable on +//! disk: it is the handoff an externally operated ThunderID loads, not an +//! internal format this crate ever reads back. Field names and document +//! shapes come from the pinned release itself (`resource_type`, `agents`, +//! `resource_servers`, `roles` directories; `inboundAuthConfig`; +//! `token.accessToken.clientConfig`; the JWKS `certificate`), never from a +//! parallel Registry-side schema. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::description::IssuerDescription; +use crate::ToolingError; + +/// The pinned release's default `agent_type` document for the `default` +/// agent type, byte-for-byte as the image ships it. The schema update starts +/// from this and only appends optional attributes; it never rewrites, +/// renames, or drops upstream fields, and a deployment sharing this schema +/// with other agents merges rather than overwrites. +const PINNED_DEFAULT_AGENT_TYPE: &str = include_str!("../fixtures/default-agent-type-v1.0.1.yaml"); + +/// Where serving-time documents land, relative to the description's state +/// root. The pinned release reads these through composite stores, so the +/// files stay files: the resource servers and roles they name are the live +/// configuration, not a one-shot import. +pub const RESOURCES_DIR: &str = "resources"; +/// The bootstrap provisioning directory the upstream one-shot consumes: the +/// additive agent-schema update plus every machine registration. The pinned +/// release's bootstrap importer is the path that persists a machine client's +/// JWKS certificate and static attributes; the serve-time file loader alone +/// does not, which is why registrations render here and not under +/// `resources/`. +pub const BOOTSTRAP_DIR: &str = "registry-schema"; + +/// Everything this render produced, including the digest an owning CLI +/// records only after the bootstrap one-shot has succeeded. +#[derive(Debug, Clone)] +pub struct RenderedResources { + /// Absolute path of the inspectable upstream YAML directory. + pub resources_dir: std::path::PathBuf, + /// Absolute path of the bootstrap provisioning directory the one-shot + /// consumes: the agent-schema update plus the machine registrations. + pub bootstrap_dir: std::path::PathBuf, + /// The pinned default agent schema document with this deployment's + /// optional attributes appended, as written. + pub agent_type_document: String, + /// sha256 of that document, for recording after successful bootstrap. + pub agent_type_sha256: String, +} + +pub(crate) fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), ToolingError> { + if path.exists() { + return Err(ToolingError::Filesystem { + reason: "a rendered document already exists; remove the session state explicitly", + }); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| ToolingError::Filesystem { + reason: "the state directory could not be created", + })?; + // Every created ancestor inside the caller's private root is made + // owner-only, not just the deepest one. The walk stops at the first + // ancestor already owner-only — the root the caller prepared — so no + // directory this crate does not own is ever touched. + let mut cursor = Some(parent); + while let Some(directory) = cursor { + let mode = fs::metadata(directory) + .map_err(|_| ToolingError::Filesystem { + reason: "the state directory could not be inspected", + })? + .permissions() + .mode(); + if mode & 0o077 != 0 { + fs::set_permissions(directory, fs::Permissions::from_mode(mode & 0o700)).map_err( + |_| ToolingError::Filesystem { + reason: "the state directory could not be made owner-only", + }, + )?; + cursor = directory.parent(); + } else { + break; + } + } + } + fs::write(path, bytes).map_err(|_| ToolingError::Filesystem { + reason: "a rendered document could not be written", + })?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| { + ToolingError::Filesystem { + reason: "a rendered document could not be made owner-only", + } + })?; + Ok(()) +} + +/// Serialize one upstream document as YAML, with the `---` document start the +/// upstream loader's multi-document files carry. +fn yaml_document(value: &Value) -> Result { + serde_norway::to_string(value).map_err(|_| ToolingError::InvalidRender { + reason: "a document did not serialize as YAML", + }) +} + +/// Parse the pinned default `agent_type` document and append this +/// deployment's optional string attributes, preserving every upstream field. +/// +/// The result contains no administrator, user, role, or password-template +/// material: it is exactly the default schema plus optional attribute +/// declarations, which is the whole reason the bootstrap one-shot can run it +/// against shared state without touching anyone else's fields. +pub fn agent_type_document(description: &IssuerDescription) -> Result { + let mut document: Value = serde_yaml_parse(PINNED_DEFAULT_AGENT_TYPE)?; + let Some(schema) = document.get_mut("schema").and_then(Value::as_object_mut) else { + return Err(ToolingError::InvalidRender { + reason: "the pinned default agent schema did not parse with a schema map", + }); + }; + for name in &description.schema_attributes { + let array = description + .machine_clients + .iter() + .filter_map(|client| client.attributes.get(name)) + .any(Value::is_array); + schema.insert( + name.clone(), + if array { + json!({ + "type": "array", + "items": {"type": "string"}, + "displayName": name, + "required": false, + }) + } else { + json!({ + "type": "string", + "displayName": name, + "required": false, + }) + }, + ); + } + yaml_document(&document) +} + +fn serde_yaml_parse(text: &str) -> Result { + serde_norway::from_str(text).map_err(|_| ToolingError::InvalidRender { + reason: "a pinned upstream document did not parse", + }) +} + +/// Render every native resource document for one validated description. +/// +/// The caller validates first; this function refuses an unvalidated shape +/// rather than guessing. Documents land in the inspectable layout the +/// upstream loader reads (`resource_servers/`, `roles/`, `agents/`, plus the +/// organization unit), and the agent-schema update lands in its own +/// one-document directory for the bootstrap one-shot. +pub fn render(description: &IssuerDescription) -> Result { + description.validate()?; + let root = description.state_root.join(RESOURCES_DIR); + let bootstrap_root = description.state_root.join(BOOTSTRAP_DIR); + + // The bootstrap bundle already created the default organization unit, + // and the upstream loader refuses a second document for an id the + // database store holds. A description built on the default OU emits no + // OU document at all; only a genuinely new OU becomes one. + if description.organization_unit.id != crate::description::DEFAULT_OU_ID { + let organization = json!({ + "resource_type": "organization_unit", + "id": description.organization_unit.id, + "handle": description.organization_unit.handle, + "name": description.organization_unit.name, + "description": description.organization_unit.description, + }); + write_owner_only( + &root + .join("organization_units") + .join(format!("{}.yaml", description.organization_unit.handle)), + yaml_document(&organization)?.as_bytes(), + )?; + } + + for server in &description.resource_servers { + let mut resources = Vec::new(); + for resource in &server.resources { + let actions: Vec = resource + .actions + .iter() + .map(|action| { + json!({ + "name": action.name, + "handle": action.handle, + "description": action.description, + "kind": "resource", + }) + }) + .collect(); + let mut rendered_resource = json!({ + "name": resource.name, + "handle": resource.handle, + "description": resource.description, + "actions": actions, + }); + if let Some(parent) = &resource.parent { + rendered_resource["parent"] = json!(parent); + } + resources.push(rendered_resource); + } + let document = json!({ + "resource_type": "resource_server", + "id": server.id, + "name": server.name, + "description": server.description, + "identifier": server.identifier, + "ouHandle": description.organization_unit.handle, + "delimiter": ":", + "resources": resources, + }); + write_owner_only( + &root + .join("resource_servers") + .join(format!("{}.yaml", server.id)), + yaml_document(&document)?.as_bytes(), + )?; + } + + for role in &description.roles { + let permissions: Vec = role + .permissions + .iter() + .map(|(server_id, permissions)| { + json!({"resourceServerId": server_id, "permissions": permissions}) + }) + .collect(); + let assignments: Vec = role + .assigned_agents + .iter() + .map(|agent_id| json!({"id": agent_id, "type": "agent"})) + .collect(); + let document = json!({ + "resource_type": "role", + "id": role.id, + "name": role.name, + "description": role.description, + "ouHandle": description.organization_unit.handle, + "permissions": permissions, + "assignments": assignments, + }); + write_owner_only( + &root.join("roles").join(format!("{}.yaml", role.id)), + yaml_document(&document)?.as_bytes(), + )?; + } + + for issuer in &description.exchange_issuers { + // Fixed user-type resolution selects mappings without consulting any + // untrusted claim. Exchange never resolves this marker to a local user. + let document = json!({ + "resource_type": "connection", "id": issuer.id, "type": "oidc", + "name": issuer.name, "issuer": issuer.issuer, + "jwksEndpoint": issuer.jwks_endpoint, "tokenExchangeEnabled": true, + "attributeConfiguration": { + "user_type_resolution": {"default": "registry-exchange"}, + "user_type_attribute_mappings": [{ + "user_type": "registry-exchange", "attributes": [{ + "external_attribute": "iss", "local_attribute": "registry_grant_source_issuer" + }] + }] + } + }); + write_owner_only( + &root.join("connections").join(format!("{}.yaml", issuer.id)), + yaml_document(&document)?.as_bytes(), + )?; + } + + for client in &description.machine_clients { + // The upstream `Certificate.Value` is a string field carrying the + // client's own JWKS JSON, which the client-assertion verifier + // unmarshals from that string — not a nested object. + let mut document = json!({ + "resource_type": "agent", + "id": client.agent_id, + "type": crate::description::DEFAULT_AGENT_TYPE, + "ouHandle": description.organization_unit.handle, + "name": client.name, + "description": client.description, + "attributes": client.attributes, + "inboundAuthConfig": [ + { + "type": "oauth2", + "config": { + "clientId": client.client_id, + "grantTypes": ["client_credentials"], + "tokenEndpointAuthMethod": "private_key_jwt", + "publicClient": false, + "certificate": { + "type": "JWKS", + "value": client.public_jwks, + }, + "token": { + "accessToken": { + "clientConfig": { + "validityPeriod": client.access_token_lifetime_seconds, + "attributes": client.token_attributes, + } + } + } + } + } + ] + }); + if client.token_exchange.is_some() { + let config = &mut document["inboundAuthConfig"][0]["config"]; + config["grantTypes"] = json!([ + "client_credentials", + "urn:ietf:params:oauth:grant-type:token-exchange" + ]); + config["token"]["accessToken"]["userConfig"] = json!({ + "validityPeriod": client.access_token_lifetime_seconds, + "attributes": crate::description::GRANT_ATTRIBUTES, + }); + } + write_owner_only( + &bootstrap_root + .join("agents") + .join(format!("{}.yaml", client.agent_id)), + yaml_document(&document)?.as_bytes(), + )?; + } + + for client in &description.compatibility_clients { + // The secret is read here, inside the private state directory, and + // written straight into the owner-only agent document that carries + // it. It never appears in a returned value, a log line, or an argv. + let secret = + fs::read_to_string(description.state_root.join(&client.secret_file)).map_err(|_| { + ToolingError::Filesystem { + reason: "a compatibility client secret file could not be read", + } + })?; + if secret.trim().is_empty() || secret.len() > 256 { + return Err(ToolingError::InvalidDescription { + reason: "a compatibility client secret file is 1..=256 bytes", + }); + } + let document = json!({ + "resource_type": "agent", + "id": client.agent_id, + "type": crate::description::DEFAULT_AGENT_TYPE, + "ouHandle": description.organization_unit.handle, + "name": client.name, + "description": client.description, + "inboundAuthConfig": [ + { + "type": "oauth2", + "config": { + "clientId": client.client_id, + "grantTypes": ["client_credentials"], + "tokenEndpointAuthMethod": client.method.as_str(), + "publicClient": false, + "clientSecret": secret.trim(), + "token": { + "accessToken": { + "clientConfig": { + "validityPeriod": 300, + } + } + } + } + } + ] + }); + write_owner_only( + &bootstrap_root + .join("agents") + .join(format!("{}.yaml", client.agent_id)), + yaml_document(&document)?.as_bytes(), + )?; + } + + let agent_type = agent_type_document(description)?; + write_owner_only( + &bootstrap_root.join("agent-type.yaml"), + agent_type.as_bytes(), + )?; + let digest = Sha256::digest(agent_type.as_bytes()); + + Ok(RenderedResources { + resources_dir: root, + bootstrap_dir: bootstrap_root, + agent_type_document: agent_type, + agent_type_sha256: hex(&digest), + }) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pinned_default_agent_schema_is_preserved_and_only_extended() { + let pinned: Value = serde_yaml_parse(PINNED_DEFAULT_AGENT_TYPE).expect("pinned parses"); + let description = crate::testing::synthetic_description(); + let extended_text = agent_type_document(&description).expect("the schema renders"); + let extended: Value = serde_yaml_parse(&extended_text).expect("the extension parses"); + + assert_eq!(extended["id"], pinned["id"], "the default type id changed"); + assert_eq!(extended["name"], pinned["name"]); + assert_eq!( + extended["schema"]["modelProvider"], pinned["schema"]["modelProvider"], + "an upstream attribute was altered" + ); + // The appended attribute is optional and additive. + assert_eq!( + extended["schema"]["synthetic_tag"]["required"], + json!(false), + "an appended attribute must stay optional" + ); + assert_eq!(extended["schema"]["synthetic_tag"]["type"], json!("string")); + // No administrator/user/role material exists in the document at all. + for forbidden in ["password", "username", "role", "administrator"] { + assert!( + !extended_text.to_lowercase().contains(forbidden), + "the schema document mentions {forbidden}" + ); + } + } + + #[test] + fn a_full_description_renders_upstream_shaped_documents() { + let mut description = crate::testing::synthetic_description(); + // A fresh state root per run: rendering refuses to overwrite, which + // is the property under test elsewhere. + description.state_root = std::env::temp_dir().join(format!( + "registry-thunderid-tooling-render-{}-{:p}", + std::process::id(), + &description + )); + let _ = std::fs::remove_dir_all(&description.state_root); + std::fs::create_dir_all(description.state_root.join("secrets")) + .expect("the secrets directory exists"); + std::fs::write( + description + .state_root + .join("secrets/compatibility-client-secret"), + "fixture-secret-not-a-real-credential", + ) + .expect("the fixture compatibility secret exists"); + let rendered = render(&description).expect("the description renders"); + let agent_text = fs::read_to_string( + rendered + .bootstrap_dir + .join("agents/0197aaaa-0000-7000-8000-0000000000a1.yaml"), + ) + .expect("the machine agent document exists"); + let agent: Value = serde_norway::from_str(&agent_text).expect("the agent document parses"); + assert_eq!(agent["resource_type"], json!("agent")); + assert_eq!(agent["type"], json!("default")); + let config = &agent["inboundAuthConfig"][0]["config"]; + assert_eq!(config["grantTypes"], json!(["client_credentials"])); + assert_eq!(config["tokenEndpointAuthMethod"], json!("private_key_jwt")); + assert_eq!(config["publicClient"], json!(false)); + assert_eq!(config["certificate"]["type"], json!("JWKS")); + // The value is the JWKS JSON as a string — upstream's Certificate + // field is a string — and it is this client's own key set, never a + // pooled one. + let embedded: Value = serde_json::from_str( + config["certificate"]["value"] + .as_str() + .expect("the JWKS is embedded as a string"), + ) + .expect("the embedded JWKS is JSON"); + assert_eq!(embedded["keys"].as_array().map(Vec::len), Some(1)); + assert_eq!( + config["token"]["accessToken"]["clientConfig"]["validityPeriod"], + json!(300) + ); + + let server_text = fs::read_to_string( + rendered + .resources_dir + .join("resource_servers/0197aaaa-0000-7000-8000-0000000000b1.yaml"), + ) + .expect("the resource server document exists"); + let server: Value = serde_norway::from_str(&server_text).expect("it parses"); + assert_eq!( + server["identifier"], + json!("urn:registry:tooling-test:evidence") + ); + assert_eq!(server["resources"][0]["handle"], json!("evidence")); + assert_eq!( + server["resources"][0]["actions"][0]["handle"], + json!("invoke") + ); + + let role_text = fs::read_to_string( + rendered + .resources_dir + .join("roles/0197aaaa-0000-7000-8000-0000000000c1.yaml"), + ) + .expect("the role document exists"); + let role: Value = serde_norway::from_str(&role_text).expect("it parses"); + assert_eq!( + role["permissions"][0]["permissions"], + json!(["evidence:invoke"]) + ); + assert_eq!(role["assignments"][0]["type"], json!("agent")); + } + #[test] + fn exchange_uses_native_user_config_and_unconditional_verified_issuer_mapping() { + use crate::description::{ExchangeIssuer, TokenExchangeClient, GRANT_ATTRIBUTES}; + let mut description = crate::testing::synthetic_description(); + description.compatibility_clients.clear(); + description.state_root = std::env::temp_dir().join(format!( + "registry-exchange-render-{}", + crate::container::random_urlsafe(16).unwrap() + )); + fs::create_dir(&description.state_root).unwrap(); + fs::set_permissions(&description.state_root, fs::Permissions::from_mode(0o700)).unwrap(); + description.exchange_issuers.push(ExchangeIssuer { + id: "0197aaaa-0000-7000-8000-0000000000d1".into(), + name: "Authority".into(), + issuer: "https://authority.example".into(), + jwks_endpoint: "https://authority.example/jwks".into(), + }); + description.machine_clients[0].token_exchange = Some(TokenExchangeClient { + assertion_resource_server_id: description.resource_servers[0].id.clone(), + assertion_scope: "evidence:invoke".into(), + }); + let rendered = render(&description).unwrap(); + let agent = serde_yaml_parse( + &fs::read_to_string( + rendered + .bootstrap_dir + .join("agents/0197aaaa-0000-7000-8000-0000000000a1.yaml"), + ) + .unwrap(), + ) + .unwrap(); + let config = &agent["inboundAuthConfig"][0]["config"]; + assert_eq!( + config["grantTypes"], + json!([ + "client_credentials", + "urn:ietf:params:oauth:grant-type:token-exchange" + ]) + ); + assert_eq!( + config["token"]["accessToken"]["userConfig"]["attributes"], + json!(GRANT_ATTRIBUTES) + ); + assert_eq!( + config["token"]["accessToken"]["clientConfig"]["attributes"], + json!(["synthetic_tag"]) + ); + let connection = serde_yaml_parse( + &fs::read_to_string( + rendered + .resources_dir + .join("connections/0197aaaa-0000-7000-8000-0000000000d1.yaml"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(connection["resource_type"], "connection"); + assert_eq!(connection["tokenExchangeEnabled"], true); + assert_eq!( + connection["attributeConfiguration"]["user_type_resolution"], + json!({"default":"registry-exchange"}) + ); + assert_eq!( + connection["attributeConfiguration"]["user_type_attribute_mappings"][0]["attributes"], + json!([{"external_attribute":"iss","local_attribute":"registry_grant_source_issuer"}]) + ); + assert!( + render(&description).is_err(), + "a second render must not overwrite session resources" + ); + fs::remove_dir_all(&description.state_root).unwrap(); + } +} diff --git a/crates/registry-thunderid-tooling/src/testing.rs b/crates/registry-thunderid-tooling/src/testing.rs new file mode 100644 index 0000000000..ea0bfb2d9b --- /dev/null +++ b/crates/registry-thunderid-tooling/src/testing.rs @@ -0,0 +1,91 @@ +//! Test-only support: the complete synthetic fixture. +//! +//! [`synthetic_description`] is the closed, product-neutral description the +//! integration tests render and load on the pinned upstream container. It +//! names no product, institution, person, or purpose — the neutral +//! vocabulary of the description type is the whole of what it exercises. A +//! stored copy of the fully rendered fixture lives under +//! `products/identity/thunderid/fixtures/` and must be regenerable from this +//! description alone. + +use std::collections::BTreeMap; + +use crate::description::{ + ClientSecretMethod, CompatibilityClient, IssuerDescription, MachineClient, OrganizationUnit, + Resource, ResourceServer, Role, SessionIdentity, +}; + +/// The ES256 public half of a deterministic test-only client key. It +/// authenticates nothing; it exists so the rendered fixture carries a +/// realistic one-key JWKS of the client's own. +pub const SYNTHETIC_CLIENT_PUBLIC_JWKS: &str = r#"{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","kid":"synthetic-client-key-1","x":"TH-XDvwYtzdc43QDOiBjfdQZTCx1k9Rz5ELDu_2NS8JW","y":"eLx0gh3VmCC2DeubmC0CdDgno7aEBYEkz5Legyg-2Go0"}]}"#; + +pub fn synthetic_description() -> IssuerDescription { + let mut attributes = BTreeMap::new(); + attributes.insert( + "synthetic_tag".to_owned(), + serde_json::Value::String("fixture-agency".to_owned()), + ); + IssuerDescription { + session: SessionIdentity { + label: "identity-integration".to_owned(), + id: "a1b2c3d4e5f60718".to_owned(), + }, + port: 18_491, + state_root: std::env::temp_dir().join("registry-thunderid-tooling-fixture"), + organization_unit: OrganizationUnit { + id: "01900000-0000-7000-8000-000000000001".to_owned(), + handle: "default".to_owned(), + name: "Default".to_owned(), + description: "Default organization unit".to_owned(), + }, + resource_servers: vec![ResourceServer { + id: "0197aaaa-0000-7000-8000-0000000000b1".to_owned(), + name: "Synthetic Evidence".to_owned(), + identifier: "urn:registry:tooling-test:evidence".to_owned(), + description: "Synthetic resource server for the integration fixture".to_owned(), + resources: vec![Resource { + name: "Evidence".to_owned(), + handle: "evidence".to_owned(), + parent: None, + description: "Synthetic evidence operations".to_owned(), + actions: vec![crate::description::Action { + name: "Invoke".to_owned(), + handle: "invoke".to_owned(), + description: "Invoke an evidence request".to_owned(), + }], + }], + }], + roles: vec![Role { + id: "0197aaaa-0000-7000-8000-0000000000c1".to_owned(), + name: "Synthetic Invoker".to_owned(), + description: "May invoke the synthetic resource".to_owned(), + permissions: vec![( + "0197aaaa-0000-7000-8000-0000000000b1".to_owned(), + vec!["evidence:invoke".to_owned()], + )], + assigned_agents: vec!["0197aaaa-0000-7000-8000-0000000000a1".to_owned()], + }], + machine_clients: vec![MachineClient { + agent_id: "0197aaaa-0000-7000-8000-0000000000a1".to_owned(), + name: "Synthetic Machine Client".to_owned(), + description: "Fixture private_key_jwt client".to_owned(), + client_id: "synthetic-machine-client".to_owned(), + public_jwks: SYNTHETIC_CLIENT_PUBLIC_JWKS.to_owned(), + attributes, + token_attributes: vec!["synthetic_tag".to_owned()], + access_token_lifetime_seconds: 300, + token_exchange: None, + }], + compatibility_clients: vec![CompatibilityClient { + agent_id: "0197aaaa-0000-7000-8000-0000000000a2".to_owned(), + name: "Synthetic Compatibility Client".to_owned(), + description: "Fixture client_secret_post client".to_owned(), + client_id: "synthetic-compatibility-client".to_owned(), + method: ClientSecretMethod::Post, + secret_file: "secrets/compatibility-client-secret".into(), + }], + exchange_issuers: vec![], + schema_attributes: vec!["synthetic_tag".to_owned()], + } +} diff --git a/crates/registry-thunderid-tooling/src/version.rs b/crates/registry-thunderid-tooling/src/version.rs new file mode 100644 index 0000000000..74182c6ea6 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/version.rs @@ -0,0 +1,117 @@ +//! The single upstream version and image pin. +//! +//! `thunderid-version.json`, beside this crate's manifest, is the only +//! maintained input naming an upstream release. Nothing here selects `latest` +//! or an unreviewed branch build, and a newer upstream release is adopted only +//! by changing this file and re-running the integration checks. + +use serde::Deserialize; + +use crate::ToolingError; + +/// The pinned upstream release this tooling renders and runs. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ThunderIdPin { + /// The upstream semantic version, e.g. `1.0.1`. + pub version: String, + /// The exact image reference including the immutable digest. + pub image: String, +} + +#[derive(Deserialize)] +struct PinFile { + schema: String, + version: String, + image: String, +} + +const PIN_FILE: &str = include_str!("../thunderid-version.json"); +const PIN_SCHEMA: &str = "registry.thunderid-pin/v1"; + +impl ThunderIdPin { + /// Load and validate the maintained pin. + pub fn load() -> Result { + Self::parse(PIN_FILE) + } + + /// Validate pin-file bytes. A reference without an `@sha256:` digest is + /// refused: it would let the same tag silently name different bytes on a + /// later pull, which is exactly what a reviewed pin exists to prevent. + fn parse(text: &str) -> Result { + let file: PinFile = serde_json::from_str(text).map_err(|_| ToolingError::InvalidPin { + reason: "the pin file is not readable JSON", + })?; + if file.schema != PIN_SCHEMA { + return Err(ToolingError::InvalidPin { + reason: "the pin file names an unknown schema", + }); + } + if file.version.is_empty() || !file.image.starts_with("ghcr.io/thunder-id/thunderid:") { + return Err(ToolingError::InvalidPin { + reason: "the pin file must name the upstream repository with a version tag", + }); + } + let (tag, digest) = file + .image + .split_once("@sha256:") + .ok_or(ToolingError::InvalidPin { + reason: "the pinned image must carry an immutable sha256 digest", + })?; + if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ToolingError::InvalidPin { + reason: "the pinned image digest is malformed", + }); + } + if !tag.ends_with(&format!(":{}", file.version)) { + return Err(ToolingError::InvalidPin { + reason: "the pinned image tag and version disagree", + }); + } + Ok(Self { + version: file.version, + image: file.image, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DIGEST: &str = "d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e"; + + fn pin_file(version: &str, image: &str) -> String { + format!("{{\"schema\":\"{PIN_SCHEMA}\",\"version\":\"{version}\",\"image\":\"{image}\"}}") + } + + #[test] + fn the_maintained_pin_loads_with_an_immutable_digest() { + let pin = ThunderIdPin::load().expect("the maintained pin is valid"); + assert_eq!(pin.version, "1.0.1"); + assert_eq!( + pin.image, + format!("ghcr.io/thunder-id/thunderid:1.0.1@sha256:{DIGEST}") + ); + } + + #[test] + fn a_floating_or_mismatched_pin_is_refused() { + for (label, image) in [ + ("a floating tag with no digest", "ghcr.io/thunder-id/thunderid:1.0.1"), + ( + "a digest under the wrong tag", + "ghcr.io/thunder-id/thunderid:1.0.2@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e", + ), + ("a truncated digest", "ghcr.io/thunder-id/thunderid:1.0.1@sha256:short"), + ("a foreign repository", "ghcr.io/other/other:1.0.1@sha256:short"), + ] { + let parsed = ThunderIdPin::parse(&pin_file("1.0.1", image)); + assert!(parsed.is_err(), "{label} was accepted: {image}"); + } + assert!(ThunderIdPin::parse(&pin_file( + "1.0.1", + &format!("ghcr.io/thunder-id/thunderid:1.0.1@sha256:{DIGEST}") + )) + .is_ok()); + } +} diff --git a/crates/registry-thunderid-tooling/thunderid-version.json b/crates/registry-thunderid-tooling/thunderid-version.json new file mode 100644 index 0000000000..b23211b617 --- /dev/null +++ b/crates/registry-thunderid-tooling/thunderid-version.json @@ -0,0 +1,10 @@ +{ + "schema": "registry.thunderid-pin/v1", + "version": "1.0.1", + "image": "ghcr.io/thunder-id/thunderid:1.0.1@sha256:d3c0613ff447a551fd440768a15bdfb6668948e80809cbb50f82d6752f22ab3e", + "source": { + "commit": "12f517c4384004e360e26eb7eb1ab0501b678bb2", + "archiveUrl": "https://codeload.github.com/thunder-id/thunderid/tar.gz/12f517c4384004e360e26eb7eb1ab0501b678bb2", + "archiveSha256": "0a572a0c970f5b08bd7c59db94ea21f75ca675291b76df5034e6eaa2aba0aa7e" + } +} diff --git a/products/identity/scripts/test-contextual-exchange.py b/products/identity/scripts/test-contextual-exchange.py new file mode 100755 index 0000000000..f90657d339 --- /dev/null +++ b/products/identity/scripts/test-contextual-exchange.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Run the real pinned-container institutional exchange proof. + +The Rust driver uses a new private state directory and a uniquely owned container. +It retains files and stops only that container. Never provide an existing state +path. It prints bounded case outcomes, never keys, assertions or access tokens. +""" +from __future__ import annotations + +import argparse +from pathlib import Path +import os +import subprocess +import tempfile +import uuid + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--driver", type=Path, help="already-built contextual-exchange example") + parser.add_argument("--state", type=Path, help="fresh absolute state directory") + parser.add_argument("--image", help="immutable local candidate sha256 image ID") + args = parser.parse_args() + root = Path(__file__).resolve().parents[3] + state = args.state or Path(tempfile.gettempdir()) / f"registry-contextual-gate0-{uuid.uuid4().hex}" + if not state.is_absolute() or state.exists(): + parser.error("--state must be an absent absolute directory") + driver = args.driver + if driver is None: + env = dict(os.environ, CARGO_INCREMENTAL="0", CARGO_PROFILE_DEV_DEBUG="0", CARGO_PROFILE_TEST_DEBUG="0") + built = subprocess.run(["cargo", "build", "--locked", "-p", "registry-thunderid-tooling", "--example", "contextual-exchange"], cwd=root, env=env, check=False) + if built.returncode: + return built.returncode + target = Path(env.get("CARGO_TARGET_DIR", root / "target")) + if not target.is_absolute(): + target = root / target + driver = target / "debug/examples/contextual-exchange" + print(f"Gate0 retained state: {state}", flush=True) + command = [str(driver.resolve()), "--state", str(state)] + if args.image: + command.extend(["--image", args.image]) + return subprocess.run(command, cwd=root, check=False).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/identity/scripts/test-thunderid-integration.py b/products/identity/scripts/test-thunderid-integration.py new file mode 100755 index 0000000000..0be0426842 --- /dev/null +++ b/products/identity/scripts/test-thunderid-integration.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Real-container ThunderID acceptance launcher for Registry Stack. + +Selects the fixed acceptance cases by ``--case`` (repeatable) or ``--all``, +lists them with ``--list``, and exits non-zero when any required subcase +fails or cannot run: a missing prerequisite is a failing skip, never a +passing one. It creates and cleans up only its own synthetic resources, on +its own retained session state, and never stops or adopts a container it +does not own. + +The launcher calls built public CLIs or test binaries; it implements no +OAuth client of its own. + +Cross-repository subcases (``--app-kit``, ``--starter``, ``--solmara``) run +only when the corresponding path is supplied; ``--all`` cannot silently omit +a repository. Record the exact source/version of every input alongside the +recorded result. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys + +# Fixed case IDs. Subcases owned by this launcher per the test-ownership +# table; first required at the package noted in parentheses. +CASES: dict[str, list[str]] = { + "A01": ["auth-valid", "auth-negative", "concurrent-replay"], # P3/P5 + "A02": ["scopes-and-cache"], # P3 + "A03": ["rust", "node", "python", "progressive", "source-oauth"], # P3/P5 + "A04": ["resource-retargeting"], # P5 + "A05": ["claims-and-admin"], # P5 + "A06": ["breg-authority", "issuer-portability"], # P4 + "A07": ["request-origin"], # P5 + "A08": ["offer-auth", "relay-route", "offer-to-evidence"], # P6 + "A09": ["schema-and-restart"], # P3 + "A10": ["client-key-rotation", "client-and-role-revocation"], # P3 + "A11": [ + "breg-fresh", + "breg-retained", + "breg-export", + "evidence-fresh", + "evidence-retained", + "source-add-and-row-denial", + "app-kit", + "starter", + "solmara", + ], # P4/P5/P6 + "A12": ["restore", "issuer-key-rotation"], # P7 + "A13": ["issuer-outage", "resource-audit-outage", "redaction"], # P7 + "A14": ["secret-methods", "qgis-token-renewal"], # P7 + "A15": ["restart-replay"], # P3 + "A16": ["archive-verification"], # P7 +} + +# The package each subcase first becomes runnable in. A case selected before +# its package is implemented fails loudly with this reason rather than +# skipping green. Packages P4 and later are not yet wired in this branch; +# see the implementation checklist in registry-internal for their status. +FIRST_REQUIRED_AT: dict[str, str] = { + "A01": "P3/P5", + "A02": "P3", + "A03": "P3/P5", + "A04": "P5", + "A05": "P5", + "A06": "P4", + "A07": "P5", + "A08": "P6", + "A09": "P3", + "A10": "P3", + "A11": "P4/P5/P6", + "A12": "P7", + "A13": "P7", + "A14": "P7", + "A15": "P3", + "A16": "P7", +} + +IMPLEMENTED_PACKAGES = {"P3"} + + +def list_cases() -> None: + for case, subcases in CASES.items(): + for subcase in subcases: + print(f"{case}.{subcase} (first required at {FIRST_REQUIRED_AT[case]})") + + +def require_docker() -> None: + try: + subprocess.run( + ["docker", "info"], capture_output=True, check=True + ) + except (OSError, subprocess.CalledProcessError) as error: + raise SystemExit( + "a running container engine is a prerequisite: the real-container " + "cases cannot run without one (this is a failing skip, not a pass)" + ) from error + + +def run_subcase(case: str, subcase: str, args: argparse.Namespace) -> bool: + package = FIRST_REQUIRED_AT[case].split("/")[0] + if package not in IMPLEMENTED_PACKAGES: + print( + f"FAIL {case}.{subcase}: first required at {FIRST_REQUIRED_AT[case]}, " + "whose owning package is not implemented on this branch; see the " + "implementation checklist in registry-internal", + file=sys.stderr, + ) + return False + # P3 subcases drive the tooling crate's session lifecycle through a test + # binary built from the owning worktree. The concrete drivers are wired + # package by package; a driver that is not built is a failing skip. + driver = args.artifacts / "registry-thunderid-integration" if args.artifacts else None + if driver is None or not driver.exists(): + print( + f"FAIL {case}.{subcase}: --artifacts DIR must name the built " + "integration test binary; without it the subcase cannot run", + file=sys.stderr, + ) + return False + result = subprocess.run( + [str(driver), "--case", f"{case}.{subcase}", "--state", str(args.state)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"FAIL {case}.{subcase}: {result.stderr.strip()}", file=sys.stderr) + return False + print(f"PASS {case}.{subcase}") + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", action="append", default=[], help="case id, e.g. A09") + parser.add_argument("--all", action="store_true", help="run every case") + parser.add_argument("--list", action="store_true", help="list case ids and subcases") + parser.add_argument( + "--artifacts", type=__import__("pathlib").Path, help="candidate binaries directory" + ) + parser.add_argument("--app-kit", type=__import__("pathlib").Path) + parser.add_argument("--starter", type=__import__("pathlib").Path) + parser.add_argument("--solmara", type=__import__("pathlib").Path) + parser.add_argument( + "--state", type=__import__("pathlib").Path, + default=__import__("pathlib").Path("/tmp/registry-thunderid-integration"), + ) + args = parser.parse_args() + + if args.list: + list_cases() + return 0 + + selected = args.case + if args.all: + selected = list(CASES) + for name, value in (("app-kit", args.app_kit), ("starter", args.starter), ("solmara", args.solmara)): + if value is None: + print( + f"--all requires --{name} for the cross-repository subcases; " + "a repository cannot be silently omitted", + file=sys.stderr, + ) + return 2 + if not selected: + print("select --case or --all, or --list", file=sys.stderr) + return 2 + for case in selected: + if case not in CASES: + print(f"unknown case {case}; use --list", file=sys.stderr) + return 2 + + require_docker() + failures = [ + (case, subcase) + for case in selected + for subcase in CASES[case] + if not run_subcase(case, subcase, args) + ] + if failures: + print(f"{len(failures)} subcase(s) failed or could not run", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agent-type.yaml b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agent-type.yaml new file mode 100644 index 0000000000..999ee95a45 --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agent-type.yaml @@ -0,0 +1,36 @@ +id: 01900000-0000-7000-8000-000000000011 +name: default +ouHandle: default +resource_type: agent_type +schema: + function: + displayName: Function + enum: + - task-automation + - rag-retrieval + - code-gen + - data-analysis + - orchestrator + - sub-agent + - assistant + - custom + required: false + type: string + model: + displayName: Model + required: false + type: string + modelProvider: + displayName: Model Provider + enum: + - openai + - anthropic + - gemini + - mistral + - custom + required: false + type: string + synthetic_tag: + displayName: synthetic_tag + required: false + type: string diff --git a/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a1.yaml b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a1.yaml new file mode 100644 index 0000000000..83bfabcd93 --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a1.yaml @@ -0,0 +1,25 @@ +attributes: + synthetic_tag: fixture-agency +description: Fixture private_key_jwt client +id: 0197aaaa-0000-7000-8000-0000000000a1 +inboundAuthConfig: +- config: + certificate: + type: JWKS + value: '{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","kid":"synthetic-client-key-1","x":"TH-XDvwYtzdc43QDOiBjfdQZTCx1k9Rz5ELDu_2NS8JW","y":"eLx0gh3VmCC2DeubmC0CdDgno7aEBYEkz5Legyg-2Go0"}]}' + clientId: synthetic-machine-client + grantTypes: + - client_credentials + publicClient: false + token: + accessToken: + clientConfig: + attributes: + - synthetic_tag + validityPeriod: 300 + tokenEndpointAuthMethod: private_key_jwt + type: oauth2 +name: Synthetic Machine Client +ouHandle: default +resource_type: agent +type: default diff --git a/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a2.yaml b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a2.yaml new file mode 100644 index 0000000000..8579400f9a --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/registry-schema/agents/0197aaaa-0000-7000-8000-0000000000a2.yaml @@ -0,0 +1,19 @@ +description: Fixture client_secret_post client +id: 0197aaaa-0000-7000-8000-0000000000a2 +inboundAuthConfig: +- config: + clientId: synthetic-compatibility-client + clientSecret: fixture-secret-not-a-real-credential + grantTypes: + - client_credentials + publicClient: false + token: + accessToken: + clientConfig: + validityPeriod: 300 + tokenEndpointAuthMethod: client_secret_post + type: oauth2 +name: Synthetic Compatibility Client +ouHandle: default +resource_type: agent +type: default diff --git a/products/identity/thunderid/fixtures/synthetic-session/resources/resource_servers/0197aaaa-0000-7000-8000-0000000000b1.yaml b/products/identity/thunderid/fixtures/synthetic-session/resources/resource_servers/0197aaaa-0000-7000-8000-0000000000b1.yaml new file mode 100644 index 0000000000..2d998fb6d9 --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/resources/resource_servers/0197aaaa-0000-7000-8000-0000000000b1.yaml @@ -0,0 +1,16 @@ +delimiter: ':' +description: Synthetic resource server for the integration fixture +id: 0197aaaa-0000-7000-8000-0000000000b1 +identifier: urn:registry:tooling-test:evidence +name: Synthetic Evidence +ouHandle: default +resource_type: resource_server +resources: +- actions: + - description: Invoke an evidence request + handle: invoke + kind: resource + name: Invoke + description: Synthetic evidence operations + handle: evidence + name: Evidence diff --git a/products/identity/thunderid/fixtures/synthetic-session/resources/roles/0197aaaa-0000-7000-8000-0000000000c1.yaml b/products/identity/thunderid/fixtures/synthetic-session/resources/roles/0197aaaa-0000-7000-8000-0000000000c1.yaml new file mode 100644 index 0000000000..871dd15947 --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/resources/roles/0197aaaa-0000-7000-8000-0000000000c1.yaml @@ -0,0 +1,12 @@ +assignments: +- id: 0197aaaa-0000-7000-8000-0000000000a1 + type: agent +description: May invoke the synthetic resource +id: 0197aaaa-0000-7000-8000-0000000000c1 +name: Synthetic Invoker +ouHandle: default +permissions: +- permissions: + - evidence:invoke + resourceServerId: 0197aaaa-0000-7000-8000-0000000000b1 +resource_type: role diff --git a/products/identity/thunderid/fixtures/synthetic-session/secrets/compatibility-client-secret b/products/identity/thunderid/fixtures/synthetic-session/secrets/compatibility-client-secret new file mode 100644 index 0000000000..1ca09db7ac --- /dev/null +++ b/products/identity/thunderid/fixtures/synthetic-session/secrets/compatibility-client-secret @@ -0,0 +1 @@ +fixture-secret-not-a-real-credential \ No newline at end of file From 51f86d64fe96ed67f221bef7ac38d435a30a3b4d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:51 +0700 Subject: [PATCH 004/120] feat(breg): checkpoint task-bound governed authorization Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- crates/registry-breg-client-node/README.md | 12 + .../__test__/token-exchange.test.js | 51 ++ crates/registry-breg-client-node/client.d.ts | 25 + crates/registry-breg-client-node/client.js | 17 + crates/registry-breg-client-node/index.d.ts | 8 + crates/registry-breg-client-node/index.js | 2 + crates/registry-breg-client-node/src/lib.rs | 88 +++ crates/registry-breg-client-py/README.md | 12 + .../python/registry_breg_client/__init__.pyi | 8 + crates/registry-breg-client-py/src/convert.rs | 40 +- crates/registry-breg-client-py/src/lib.rs | 53 ++ .../tests/python/exchange-test-key.json | 1 + .../tests/python/test_token_exchange.py | 57 ++ crates/registry-breg/src/access.rs | 41 +- .../src/action_evidence_config.rs | 2 +- crates/registry-breg/src/api/actions.rs | 26 +- crates/registry-breg/src/api/context.rs | 75 ++ crates/registry-breg/src/api/gis.rs | 6 +- crates/registry-breg/src/api/mod.rs | 119 ++- .../api/tests/change_request_action_tests.rs | 4 +- .../api/tests/change_request_read_tests.rs | 8 +- .../src/api/tests/immediate_action_tests.rs | 57 +- crates/registry-breg/src/artifacts.rs | 26 +- crates/registry-breg/src/auth.rs | 177 ++++- crates/registry-breg/src/authority.rs | 2 +- crates/registry-breg/src/change_request.rs | 165 ++-- crates/registry-breg/src/compiler.rs | 175 +++- crates/registry-breg/src/contract.rs | 202 +++-- crates/registry-breg/src/evidence_source.rs | 4 +- .../src/evidence_source/tests.rs | 47 +- crates/registry-breg/src/fixtures.rs | 48 +- crates/registry-breg/src/generated_ddl.rs | 36 +- crates/registry-breg/src/idempotency.rs | 11 +- crates/registry-breg/src/immediate_actions.rs | 143 ++-- crates/registry-breg/src/lib.rs | 2 + crates/registry-breg/src/model.rs | 24 +- crates/registry-breg/src/mutation.rs | 45 ++ crates/registry-breg/src/mutation/action.rs | 2 +- crates/registry-breg/src/mutation/request.rs | 50 ++ .../src/package/tests/immediate_actions.rs | 8 +- crates/registry-breg/src/postgres/context.rs | 60 +- crates/registry-breg/src/postgres/mutation.rs | 13 + crates/registry-breg/src/postgres/read.rs | 8 +- .../src/postgres/request_read.rs | 10 +- .../src/request_prepare/tests.rs | 6 +- crates/registry-breg/src/request_retention.rs | 16 +- crates/registry-breg/src/request_store.rs | 120 +++ crates/registry-breg/src/runtime_config.rs | 94 ++- crates/registry-breg/src/schema.rs | 14 +- crates/registry-breg/src/startup.rs | 4 + crates/registry-breg/src/task_grant.rs | 259 ++++++ crates/registry-breg/src/task_grant/config.rs | 181 +++++ crates/registry-breg/src/task_grant/tests.rs | 187 +++++ .../tests/artifacts_response_schema_tests.rs | 14 +- .../tests/access_configuration.rs | 73 +- .../registry-breg/tests/access_explanation.rs | 16 +- .../tests/action_evidence_compiler.rs | 2 +- .../tests/change_request_diagnostics.rs | 12 +- .../registry-breg/tests/compiler_contract.rs | 213 ++--- .../tests/compiler_request_attachments.rs | 24 +- .../tests/compiler_rhai_planner.rs | 18 +- .../tests/compiler_submitter_targets.rs | 10 +- .../registry-breg/tests/compiler_webhook.rs | 6 +- crates/registry-breg/tests/data_operations.rs | 12 +- .../tests/explicit_row_authority.rs | 21 +- crates/registry-breg/tests/fixture_tooling.rs | 20 +- .../fixtures/action-authority-mapping.yaml | 4 +- .../tests/fixtures/authority-mapping.yaml | 4 +- .../fixtures/fixed-optional-action-input.yaml | 2 +- .../fixtures/fixture-tooling/project.yaml | 2 +- crates/registry-breg/tests/http_auth.rs | 216 ++++- crates/registry-breg/tests/http_read_only.rs | 46 +- .../tests/immediate_action_compiler.rs | 28 +- .../tests/immediate_action_handler.rs | 8 +- .../tests/immediate_action_requirements.rs | 4 +- .../registry-breg/tests/membership_access.rs | 24 +- crates/registry-breg/tests/native_patterns.rs | 2 +- .../tests/package_change_plan.rs | 16 +- .../tests/postgres_action_evidence_targets.rs | 2 +- .../tests/postgres_action_handlers.rs | 4 +- .../tests/postgres_anonymous_refusals.rs | 4 +- .../tests/postgres_audit_tooling.rs | 2 +- crates/registry-breg/tests/postgres_batch.rs | 6 +- .../tests/postgres_change_requests.rs | 60 +- .../tests/postgres_client_capabilities.rs | 2 +- .../tests/postgres_client_relationships.rs | 2 +- .../tests/postgres_compiled_schema.rs | 8 +- .../tests/postgres_constraint_races.rs | 2 +- .../tests/postgres_data_export.rs | 2 +- .../tests/postgres_historical.rs | 4 +- .../tests/postgres_history_commit.rs | 2 +- .../tests/postgres_history_erasure.rs | 2 +- .../tests/postgres_history_rebaseline.rs | 2 +- .../postgres_immediate_action_activation.rs | 4 +- .../tests/postgres_immediate_actions.rs | 6 +- .../tests/postgres_membership_access.rs | 4 +- .../registry-breg/tests/postgres_mutation.rs | 10 +- .../tests/postgres_partial_unique.rs | 2 +- crates/registry-breg/tests/postgres_read.rs | 6 +- .../tests/postgres_read_dependencies.rs | 4 +- .../tests/postgres_request_activation.rs | 8 +- .../tests/postgres_request_authority.rs | 14 +- .../tests/postgres_request_events.rs | 10 +- .../tests/postgres_request_queries.rs | 8 +- .../tests/postgres_request_read_retention.rs | 38 +- .../postgres_request_upgrade_retention.rs | 4 +- .../tests/postgres_revision_http.rs | 4 +- .../tests/postgres_rhai_planner.rs | 2 +- .../tests/postgres_spatial_read.rs | 2 +- .../tests/postgres_spatial_storage.rs | 8 +- .../tests/postgres_task_grants.rs | 746 ++++++++++++++++++ .../tests/postgres_temporal_corrections.rs | 2 +- .../tests/postgres_tombstone_revision.rs | 2 +- .../tests/postgres_webhook_delivery.rs | 2 +- .../tests/postgres_webhook_outbox.rs | 2 +- .../tests/request_visibility_contract.rs | 10 +- .../tests/schema_fingerprint_rehearsal.rs | 2 +- .../registry-breg/tests/starter_projects.rs | 2 +- crates/registry-breg/tests/startup_http.rs | 2 +- .../tests/support/action_requirements.rs | 2 +- .../immediate_action_review_regressions.rs | 4 +- .../tests/support/membership_fixture.rs | 4 +- .../tests/support/reviewer_reasons.rs | 2 +- .../tests/support/submitter_targets.rs | 2 +- crates/registry-bregctl/Cargo.toml | 4 +- crates/registry-bregctl/src/data_lifecycle.rs | 2 +- crates/registry-bregctl/src/dev/config.rs | 164 ++-- crates/registry-bregctl/src/dev/events.rs | 2 +- crates/registry-bregctl/src/dev/examples.rs | 12 +- .../registry-bregctl/src/dev/export_client.rs | 7 +- crates/registry-bregctl/src/dev/mod.rs | 361 ++++++--- .../src/dev/prepare_source.rs | 80 +- crates/registry-bregctl/src/dev/tests.rs | 198 +++-- .../src/init_from_model/render.rs | 6 +- crates/registry-bregctl/src/lib.rs | 26 +- crates/registry-bregctl/tests/cli.rs | 24 +- .../registry-bregctl/tests/dev_lifecycle.rs | 236 +++++- products/breg/ACCEPTANCE-JOURNEYS.md | 2 +- products/breg/CHANGE_REQUEST_EXAMPLES.md | 14 +- products/breg/DECISIONS.md | 2 +- products/breg/DEV.md | 54 +- products/breg/EVIDENCE.md | 2 +- products/breg/IMPLEMENTATION.md | 2 +- products/breg/README.md | 2 + products/breg/SPATIAL-QUERIES.md | 2 +- products/breg/TASK_GRANTS.md | 91 +++ .../registry.yaml | 12 +- .../asset-site-placement/registry.yaml | 4 +- .../business-establishments/registry.yaml | 4 +- .../breg/acceptance/business/registry.yaml | 4 +- .../breg/acceptance/facility/registry.yaml | 2 +- .../farmer-landholding-evidence/README.md | 4 +- .../farmer-landholding-evidence/registry.yaml | 4 +- .../household-history/registry.yaml | 4 +- .../breg/acceptance/inspection/registry.yaml | 2 +- .../issuer-portability/registry.yaml | 2 +- .../person-name-change-rhai/registry.yaml | 6 +- .../person-registration-rhai/README.md | 2 +- .../person-registration-rhai/registry.yaml | 6 +- .../registry.yaml | 12 +- .../publicschema-household/registry.yaml | 4 +- .../registry-record-conformance/registry.yaml | 6 +- .../request-attachments/registry.yaml | 10 +- .../spatial-service-sites/registry.yaml | 12 +- .../contracts/acceptance-scenario-matrix.yaml | 2 +- .../breg/contracts/definition-of-done.yaml | 4 +- .../contracts/security-invariant-matrix.yaml | 9 +- .../contracts/security-test-traceability.yaml | 1 + products/breg/evidence/registry/registry.yaml | 4 +- .../breg/evidence/tests/verify-composition.py | 10 +- .../breg/examples/access-review/README.md | 8 +- .../breg/examples/access-review/registry.yaml | 2 +- .../access-review/task-profiles/registry.yaml | 18 +- .../asset-registration-actions/registry.yaml | 4 +- .../facility-registry-actions/registry.yaml | 8 +- .../household-contact-actions/registry.yaml | 6 +- .../registry.yaml | 4 +- .../compiled/actions.json | 2 +- .../generated/metadata/registry.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 8 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 112 +-- .../schemas/asset-placement.schema.json | 2 +- .../placement-correction-request.schema.json | 2 +- .../authoring/registry-project.schema.json | 107 ++- .../compiled/actions.json | 2 +- .../generated/metadata/registry.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 18 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 32 +- .../person-name-change-request.schema.json | 2 +- .../generated/schemas/person.schema.json | 2 +- .../compiled/actions.json | 2 +- .../generated/metadata/registry.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 18 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 130 +-- .../schemas/group-membership.schema.json | 2 +- .../generated/schemas/household.schema.json | 2 +- .../generated/schemas/person.schema.json | 2 +- ...ster-household-contact-request.schema.json | 2 +- .../generated/openapi.json | 2 +- .../generated/postgres/schema.sql | 60 +- .../schemas/correction-request.schema.json | 2 +- .../generated/schemas/record.schema.json | 2 +- .../generated/runtime/runtime.schema.json | 115 +++ products/breg/immediate-actions.md | 18 +- products/breg/membership-access.md | 6 +- products/breg/metadata.md | 6 +- .../breg/scripts/test_validate_product.py | 2 +- products/breg/scripts/validate_product.py | 2 +- .../agricultural-holdings/core/MODEL.md | 4 +- .../agricultural-holdings/core/registry.yaml | 6 +- .../professional-licences/core/MODEL.md | 4 +- .../professional-licences/core/registry.yaml | 8 +- .../public-organizations/core/MODEL.md | 4 +- .../public-organizations/core/registry.yaml | 6 +- .../starters/seed-lots/core/registry.yaml | 6 +- 221 files changed, 5354 insertions(+), 1507 deletions(-) create mode 100644 crates/registry-breg-client-node/__test__/token-exchange.test.js create mode 100644 crates/registry-breg-client-py/tests/python/exchange-test-key.json create mode 100644 crates/registry-breg-client-py/tests/python/test_token_exchange.py create mode 100644 crates/registry-breg/src/task_grant.rs create mode 100644 crates/registry-breg/src/task_grant/config.rs create mode 100644 crates/registry-breg/src/task_grant/tests.rs create mode 100644 crates/registry-breg/tests/postgres_task_grants.rs create mode 100644 products/breg/TASK_GRANTS.md diff --git a/crates/registry-breg-client-node/README.md b/crates/registry-breg-client-node/README.md index beeef7b864..674b3c901d 100644 --- a/crates/registry-breg-client-node/README.md +++ b/crates/registry-breg-client-node/README.md @@ -198,3 +198,15 @@ their stored content type. A retained older value may carry a content type the current policy no longer accepts. The response is bounded by both the slot capacity and the client's `maxResponseBytes`, so raise `maxResponseBytes` when a slot may hold more than the default bound. + +## Explicit OAuth token exchange + +`breg.PrivateKeyJwt` wraps the shared Rust OAuth provider. Construct it with the +same `PrivateKeyJwtConfig` used by client authorization. Call +`await provider.exchange(subjectToken)` to exchange a short-lived signed task +assertion for an access token. Set `resource` and `scopes` explicitly. Every +exchange authenticates the client afresh and bypasses the ordinary service +cache; `await provider.bearerToken()` uses that separate client-credentials +cache. Neither method logs or persists the returned credential. The application +must protect it and pass it explicitly to a resource client. The resource server +still validates the signed authority and bounds. diff --git a/crates/registry-breg-client-node/__test__/token-exchange.test.js b/crates/registry-breg-client-node/__test__/token-exchange.test.js new file mode 100644 index 0000000000..f1cf3a1099 --- /dev/null +++ b/crates/registry-breg-client-node/__test__/token-exchange.test.js @@ -0,0 +1,51 @@ +'use strict'; +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const http = require('node:http'); +const { test } = require('node:test'); +const { PrivateKeyJwt, BaseRegistryClientError } = require('..'); + +test('exchange preserves service cache and sends exact independent grant requests', async () => { + const requests = []; + const server = http.createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk; + const form = new URLSearchParams(body); + requests.push(form); + const subject = form.get('subject_token'); + response.writeHead(200, {'content-type':'application/json', 'cache-control':'no-store', pragma:'no-cache'}); + response.end(JSON.stringify({access_token: subject || 'service-token', token_type:'Bearer', + expires_in:300, scope:'records:get', issued_token_type:'urn:ietf:params:oauth:token-type:access_token'})); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + try { + const clientKey = crypto.generateKeyPairSync('ec', {namedCurve:'prime256v1'}).privateKey.export({format:'jwk'}); + Object.assign(clientKey, {alg:'ES256', kid:'synthetic-test'}); + const config = {tokenEndpoint:`http://127.0.0.1:${server.address().port}/token`, clientId:'test-client', + clientKey, resource:'urn:test:resource', scopes:['records:get']}; + const provider = new PrivateKeyJwt(config); + assert.equal(await provider.bearerToken(), 'service-token'); + assert.deepEqual(await Promise.all([provider.exchange('first-subject'), provider.exchange('second-subject')]), + ['first-subject', 'second-subject']); + assert.equal(await provider.bearerToken(), 'service-token'); + assert.equal(requests.length, 3); + for (const form of requests.slice(1)) { + assert.equal(form.get('grant_type'), 'urn:ietf:params:oauth:grant-type:token-exchange'); + assert.equal(form.get('subject_token_type'), 'urn:ietf:params:oauth:token-type:jwt'); + assert.equal(form.get('resource'), 'urn:test:resource'); + assert.equal(form.get('scope'), 'records:get'); + assert.equal(form.has('client_secret'), false); + } + assert.notEqual(requests[1].get('client_assertion'), requests[2].get('client_assertion')); + await assert.rejects(provider.exchange('bad\nsubject'), error => error instanceof BaseRegistryClientError && !error.message.includes('bad')); + await assert.rejects(new PrivateKeyJwt({...config, resource:null}).exchange('subject')); + assert.equal(requests.length, 3); + } finally { await new Promise(resolve => server.close(resolve)); } +}); + +test('provider configuration rejects executable properties without invoking them', () => { + let accessed = false; + const config = { get clientKey() { accessed = true; throw new Error('secret-canary'); } }; + assert.throws(() => new PrivateKeyJwt(config), error => error instanceof BaseRegistryClientError && !error.message.includes('secret-canary')); + assert.equal(accessed, false); +}); diff --git a/crates/registry-breg-client-node/client.d.ts b/crates/registry-breg-client-node/client.d.ts index e4e8fcc9ad..d03b6dd877 100644 --- a/crates/registry-breg-client-node/client.d.ts +++ b/crates/registry-breg-client-node/client.d.ts @@ -53,7 +53,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: PrivateJwk + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string | null + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string | null + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray | null assertionLifetimeSeconds?: SafeInteger | null refreshMarginSeconds?: SafeInteger | null requestTimeoutMilliseconds?: SafeInteger | null @@ -62,6 +78,15 @@ export interface PrivateKeyJwtConfig { trustedRootCertificates?: string | null } +/** OAuth credentials are returned to the caller. Keep them out of logs and persistence. */ +export class PrivateKeyJwt { + constructor(config: PrivateKeyJwtConfig) + /** Fresh RFC 8693 exchange. Requires resource and scopes; never shares the service cache. */ + exchange(subjectToken: string): Promise + /** Client credentials with the shared provider's bounded service-token cache. */ + bearerToken(): Promise +} + export type BaseRegistryAuthorization = | { static: string } | { privateKeyJwt: PrivateKeyJwtConfig } diff --git a/crates/registry-breg-client-node/client.js b/crates/registry-breg-client-node/client.js index cbf80f2cea..9f3600288b 100644 --- a/crates/registry-breg-client-node/client.js +++ b/crates/registry-breg-client-node/client.js @@ -346,8 +346,25 @@ class BaseRegistryClient extends native.BaseRegistryClient { } } +class PrivateKeyJwt { + #native; + constructor(config) { + try { this.#native = new native.PrivateKeyJwt(cloneJson(config, { nodes: 0, stringBytes: 0, active: new WeakSet() }, 0, 'configuration')); } + catch (error) { throw normalize(error, 'configuration'); } + } + async exchange(subjectToken) { + try { return await this.#native.exchange(subjectToken); } + catch (error) { throw normalize(error, 'token'); } + } + async bearerToken() { + try { return await this.#native.bearerToken(); } + catch (error) { throw normalize(error, 'token'); } + } +} + module.exports = { verifyWebhookDelivery, + PrivateKeyJwt, BaseRegistryClient, BaseRegistryClientError, BRegMetadata: native.BRegMetadata, diff --git a/crates/registry-breg-client-node/index.d.ts b/crates/registry-breg-client-node/index.d.ts index 9af3be9994..fa7f2e6c63 100644 --- a/crates/registry-breg-client-node/index.d.ts +++ b/crates/registry-breg-client-node/index.d.ts @@ -244,6 +244,14 @@ export declare class BRegTombstoneBinding { } export type TombstoneBinding = BRegTombstoneBinding +/** Shared OAuth client authentication and uncached task-token exchange. */ +export declare class PrivateKeyJwt { + constructor(config: any) + exchange(subjectToken: string): Promise + bearerToken(): Promise +} +export type PrivateKeyJwtBinding = PrivateKeyJwt + export interface CompleteOutcome { kind: string value: any diff --git a/crates/registry-breg-client-node/index.js b/crates/registry-breg-client-node/index.js index 684129fb4f..afed98a5cf 100644 --- a/crates/registry-breg-client-node/index.js +++ b/crates/registry-breg-client-node/index.js @@ -732,3 +732,5 @@ module.exports.RecoveredLifecycle = nativeBinding.RecoveredLifecycle module.exports.BRegTombstoneBinding = nativeBinding.BRegTombstoneBinding module.exports.TombstoneBinding = nativeBinding.TombstoneBinding module.exports.verifyWebhookDelivery = nativeBinding.verifyWebhookDelivery +module.exports.PrivateKeyJwt = nativeBinding.PrivateKeyJwt +module.exports.PrivateKeyJwtBinding = nativeBinding.PrivateKeyJwtBinding diff --git a/crates/registry-breg-client-node/src/lib.rs b/crates/registry-breg-client-node/src/lib.rs index 37fc7deafe..0001336df7 100644 --- a/crates/registry-breg-client-node/src/lib.rs +++ b/crates/registry-breg-client-node/src/lib.rs @@ -394,6 +394,30 @@ fn optional_string( } } +/// An optional member that, when present, must be an array of strings. The +/// values' own grammar is the provider's to check; this holds only the shape. +fn optional_string_array( + object: &Map, + field: &str, + kind: &'static str, + message: &'static str, +) -> Result>> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(values)) => values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| binding_error(kind, message)) + }) + .collect::>>() + .map(Some), + Some(_) => Err(binding_error(kind, message)), + } +} + fn safe_integer( value: &Value, minimum: i64, @@ -463,6 +487,8 @@ fn private_key_jwt(value: &Value) -> Result { "clientId", "clientKey", "audience", + "resource", + "scopes", "assertionLifetimeSeconds", "refreshMarginSeconds", "requestTimeoutMilliseconds", @@ -511,6 +537,22 @@ fn private_key_jwt(value: &Value) -> Result { )? { config = config.with_audience(value); } + if let Some(value) = optional_string( + object, + "resource", + "configuration", + "authorization.privateKeyJwt.resource must be a string", + )? { + config = config.with_resource(value); + } + if let Some(values) = optional_string_array( + object, + "scopes", + "configuration", + "authorization.privateKeyJwt.scopes must be an array of strings", + )? { + config = config.with_scopes(values); + } if let Some(value) = optional_i64( object, "assertionLifetimeSeconds", @@ -560,6 +602,52 @@ fn private_key_jwt(value: &Value) -> Result { PrivateKeyJwt::new(config).map_err(|error| mapped_error(token_error_value(error))) } +/// Shared OAuth client authentication and uncached task-token exchange. +#[napi(js_name = "PrivateKeyJwt")] +pub struct PrivateKeyJwtBinding { + inner: PrivateKeyJwt, +} + +#[napi] +impl PrivateKeyJwtBinding { + #[napi(constructor)] + pub fn new(config: Value) -> Result { + Ok(Self { + inner: private_key_jwt(&config)?, + }) + } + + #[napi] + pub async fn exchange(&self, subject_token: String) -> Result { + let token = self + .inner + .exchange(&subject_token) + .await + .map_err(|error| mapped_error(token_error_value(error)))?; + bearer_value(token) + } + + #[napi] + pub async fn bearer_token(&self) -> Result { + let token = self + .inner + .bearer_token() + .await + .map_err(|error| mapped_error(token_error_value(error)))?; + bearer_value(token) + } +} + +fn bearer_value(token: registry_breg_client::BearerToken) -> Result { + let header = token.authorization_header_value(); + header + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_owned) + .ok_or_else(|| binding_error("token", "the issued credential is invalid")) +} + fn authorization_provider(value: &Value) -> Result>> { if value.is_null() { return Ok(None); diff --git a/crates/registry-breg-client-py/README.md b/crates/registry-breg-client-py/README.md index d183e1648c..8c97843222 100644 --- a/crates/registry-breg-client-py/README.md +++ b/crates/registry-breg-client-py/README.md @@ -154,3 +154,15 @@ stored content type. A retained older value may carry a content type the current policy no longer accepts. The response is bounded by both the slot capacity and the client's `max_response_bytes`, so raise `max_response_bytes` when a slot may hold more than the default bound. + +## Explicit OAuth token exchange + +`registry_client.breg.PrivateKeyJwt(config)` wraps the shared Rust OAuth +provider. Its configuration uses the existing snake-case `private_key_jwt` +fields. Call `provider.exchange(subject_token)` with a short-lived signed task +assertion and explicit configured `resource` and `scopes`. Each exchange uses +fresh client authentication and bypasses the service cache; +`provider.bearer_token()` uses the separate client-credentials cache. Both +methods release the GIL during network work and return a credential string for +explicit use by the application. Keep that string out of logs and persistent +state. The resource server still validates the signed authority and bounds. diff --git a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi index f3e89b0d3d..83d17c414e 100644 --- a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi +++ b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi @@ -451,3 +451,11 @@ class BaseRegistryClient: ) -> dict[str, Any]: ... __version__: str + +class PrivateKeyJwt: + """Shared OAuth provider. Returned credentials must be kept out of logs.""" + def __init__(self, config: dict[str, Any]) -> None: ... + def exchange(self, subject_token: str) -> str: + """Fresh RFC 8693 exchange, requiring configured resource and scopes.""" + ... + def bearer_token(self) -> str: ... diff --git a/crates/registry-breg-client-py/src/convert.rs b/crates/registry-breg-client-py/src/convert.rs index a13152e614..9e64772b15 100644 --- a/crates/registry-breg-client-py/src/convert.rs +++ b/crates/registry-breg-client-py/src/convert.rs @@ -260,6 +260,33 @@ fn optional_string( } } +/// An optional member that, when present, must be a list of strings. The +/// values' own grammar is the provider's to check; this holds only the shape. +fn optional_string_list( + value: &Map, + field: &str, + what: &str, +) -> Result>, ConversionError> { + let Some(values) = value.get(field) else { + return Ok(None); + }; + if values.is_null() { + return Ok(None); + } + let values = values.as_array().ok_or_else(|| { + ConversionError::new(format!("{what}[\"{field}\"] must be a list of strings")) + })?; + values + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + ConversionError::new(format!("{what}[\"{field}\"] must contain only strings")) + }) + }) + .collect::, _>>() + .map(Some) +} + fn optional_i64( value: &Map, field: &str, @@ -362,7 +389,10 @@ impl From for ConfigError { } } -fn private_key_jwt(value: &Value, roots: Option>) -> Result { +pub(crate) fn private_key_jwt( + value: &Value, + roots: Option>, +) -> Result { const WHAT: &str = "authorization[\"private_key_jwt\"]"; let value = value .as_object() @@ -374,6 +404,8 @@ fn private_key_jwt(value: &Value, roots: Option>) -> Result>) -> Result, config: &Bound<'_, PyAny>) -> PyResult { + let outer = PyDict::new(py); + outer.set_item("private_key_jwt", config)?; + let (value, roots) = authorization_from_python(Some(outer.as_any())) + .map_err(|error| conversion_error(py, "configuration", error))?; + let inner = convert::private_key_jwt(&value["private_key_jwt"], roots) + .map_err(|error| config_error(py, error))?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| PyRuntimeError::new_err("the token runtime could not be created"))?; + Ok(Self { inner, runtime }) + } + + fn exchange(&self, py: Python<'_>, subject_token: &str) -> PyResult { + let token = py + .detach(|| self.runtime.block_on(self.inner.exchange(subject_token))) + .map_err(|error| token_error(py, error))?; + Self::bearer_value(token) + } + + fn bearer_token(&self, py: Python<'_>) -> PyResult { + use breg_client_sdk::TokenProvider; + let token = py + .detach(|| self.runtime.block_on(self.inner.bearer_token())) + .map_err(|error| token_error(py, error))?; + Self::bearer_value(token) + } +} + +impl PrivateKeyJwtBinding { + fn bearer_value(token: breg_client_sdk::BearerToken) -> PyResult { + let header = token.authorization_header_value(); + header + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::to_owned) + .ok_or_else(|| PyRuntimeError::new_err("the issued credential is invalid")) + } +} + #[pymodule] fn registry_breg_client(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; diff --git a/crates/registry-breg-client-py/tests/python/exchange-test-key.json b/crates/registry-breg-client-py/tests/python/exchange-test-key.json new file mode 100644 index 0000000000..bc76358c8b --- /dev/null +++ b/crates/registry-breg-client-py/tests/python/exchange-test-key.json @@ -0,0 +1 @@ +{"kty":"EC","x":"sSD569mxjwcZxkDUq463kqmDgDSscKCSWV4TR5zdBPM","y":"SThkdJCz_w7sx4p8h7MIn7yAXF-8m7Im2TVwMPMpCbE","crv":"P-256","d":"isF0U1l4z-aAFdrxuJUCuC2DkyZnsVP6ntAUtEpNCrk","kid":"synthetic-exchange-test","alg":"ES256"} diff --git a/crates/registry-breg-client-py/tests/python/test_token_exchange.py b/crates/registry-breg-client-py/tests/python/test_token_exchange.py new file mode 100644 index 0000000000..d49b4071d0 --- /dev/null +++ b/crates/registry-breg-client-py/tests/python/test_token_exchange.py @@ -0,0 +1,57 @@ +"""Language-level proof of the shared OAuth exchange contract using synthetic credentials.""" +import json +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs +from bootstrap import ensure_built + +ensure_built() +from registry_breg_client import PrivateKeyJwt, BaseRegistryClientError + + +class TokenExchangeTests(unittest.TestCase): + def test_exchange_is_uncached_and_preserves_service_cache(self): + requests = [] + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + form = parse_qs(self.rfile.read(int(self.headers['content-length'])).decode()) + requests.append(form) + body = json.dumps({'access_token':form.get('subject_token', ['service-token'])[0], + 'token_type':'Bearer', 'expires_in':300, 'scope':'records:get', + 'issued_token_type':'urn:ietf:params:oauth:token-type:access_token'}).encode() + self.send_response(200) + for name, value in [('content-type','application/json'), ('cache-control','no-store'), + ('pragma','no-cache'), ('content-length',str(len(body)))]: + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): pass + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + config = {'token_endpoint':f'http://127.0.0.1:{server.server_port}/token', + 'client_id':'test-client', 'client_key':json.loads(Path(__file__).with_name('exchange-test-key.json').read_text()), + 'resource':'urn:test:resource', 'scopes':['records:get']} + provider = PrivateKeyJwt(config) + self.assertEqual(provider.bearer_token(), 'service-token') + self.assertEqual(provider.exchange('first-subject'), 'first-subject') + self.assertEqual(provider.exchange('second-subject'), 'second-subject') + self.assertEqual(provider.bearer_token(), 'service-token') + self.assertEqual(len(requests), 3) + for form in requests[1:]: + self.assertEqual(form['grant_type'], ['urn:ietf:params:oauth:grant-type:token-exchange']) + self.assertEqual(form['subject_token_type'], ['urn:ietf:params:oauth:token-type:jwt']) + self.assertEqual(form['resource'], ['urn:test:resource']) + self.assertEqual(form['scope'], ['records:get']) + self.assertNotIn('client_secret', form) + self.assertNotEqual(requests[1]['client_assertion'], requests[2]['client_assertion']) + with self.assertRaises(BaseRegistryClientError): provider.exchange('bad\nsubject') + with self.assertRaises(BaseRegistryClientError): PrivateKeyJwt({**config, 'resource':None}).exchange('subject') + self.assertEqual(len(requests), 3) + finally: + server.shutdown() + server.server_close() + thread.join() diff --git a/crates/registry-breg/src/access.rs b/crates/registry-breg/src/access.rs index f77d0a53e1..733a6c3457 100644 --- a/crates/registry-breg/src/access.rs +++ b/crates/registry-breg/src/access.rs @@ -192,7 +192,7 @@ pub(crate) fn access_findings(entities: &BTreeMap) -> Vec< &format!("this profile grants {} and names no writable field, so every write naming a field is refused and a required field can never be supplied. List the fields this profile may write, or remove the write operations", write_operations.join(", ")))); } // A row boundary compiles to an INSERT `WITH CHECK` pinning its field to - // the caller's claim, so a grant that creates must keep the field + // the caller's claim, so a permission that creates must keep the field // writable. The record id is never a writable field, so a boundary on it // is outside that advice. let creates = profile.operations.contains(&Operation::Create); @@ -230,7 +230,7 @@ pub(crate) fn access_findings(entities: &BTreeMap) -> Vec< .filter(|field| !profile.writable_fields.contains(**field)) { findings.push(Diagnostic::finding("access.profile.row_boundary_not_writable", format!("{path}.writableFields"), - &format!("create is granted, but row boundary field `{field}` is not writable: a create cannot name it, the row policy pins it to the caller's claim on insert, and every create is refused. Add `{field}` to writableFields, or remove create from this grant"))); + &format!("create is permitted, but row boundary field `{field}` is not writable: a create cannot name it, the row policy pins it to the caller's claim on insert, and every create is refused. Add `{field}` to writableFields, or remove create from this permission"))); } } if profile.revision_access && profile.operations.contains(&Operation::Revisions) { @@ -260,23 +260,23 @@ pub(crate) fn access_findings(entities: &BTreeMap) -> Vec< "this field is more sensitive than its entity's classification; verify the profile's scope and purpose before disclosing it")); } } - for grant in &profile.read_paths { - findings.push(Diagnostic::finding("access.profile.related_disclosure", format!("{path}.readPaths[path={}]", grant.path), - "this grant discloses related records using the root profile, not target direct-access profiles; review its fields and the target/through entity accessRequirements")); + for permission in &profile.read_paths { + findings.push(Diagnostic::finding("access.profile.related_disclosure", format!("{path}.readPaths[path={}]", permission.path), + "this permission discloses related records using the root profile, not target direct-access profiles; review its fields and the target/through entity accessRequirements")); } } } findings } -/// Findings on target grants after their types and referenced entities have compiled. +/// Findings on target permissions after their types and referenced entities have compiled. pub(crate) fn compiled_access_findings( entities: &BTreeMap, actions: &CompiledActionInventory, ) -> Vec { let mut findings = Vec::new(); for reach in row_reach(entities, actions) { - // Ordinary grants retain their existing finding codes above. + // Ordinary permissions retain their existing finding codes above. if reach.surface == "entity" { continue; } @@ -286,14 +286,14 @@ pub(crate) fn compiled_access_findings( .is_some_and(|entity| entity.classification != Classification::Public) { findings.push(Diagnostic::finding("access.target.unrestricted_rows", &reach.source_path, - "this target grant has no claim-bound row restriction, within its configured operation and field limits. Review this registry-wide target authority")); + "this target permission has no claim-bound row restriction, within its configured operation and field limits. Review this registry-wide target authority")); } } for action in &actions.actions { - for grant in &action.grants { + for grant in &action.permissions { if !grant.anonymous && grant.required_scopes.is_empty() { findings.push(Diagnostic::finding("access.action.no_required_scope", - format!("actions[id={}].grants[profile={}].requiredScopes", action.id, grant.profile_id), + format!("actions[id={}].permissions[profile={}].requiredScopes", action.id, grant.profile_id), "no scope restricts who may select this action profile; any authenticated principal satisfying its purpose and target claims qualifies. Add a required scope unless this is intended")); } } @@ -328,7 +328,7 @@ pub struct EntityAccessExplanation { pub profiles: Vec, } -/// Configuration locations identify compiled grants, not original file line numbers. +/// Configuration locations identify compiled permissions, not original file line numbers. #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RowReachExplanation { @@ -446,13 +446,13 @@ fn row_reach( } } for action in &actions.actions { - for grant in &action.grants { + for grant in &action.permissions { for target in &grant.targets { add( &target.entity_id, &grant.profile_id, format!( - "actions[id={}].grants[profile={}].targets[entity={}].rowBoundaries", + "actions[id={}].permissions[profile={}].targets[entity={}].rowBoundaries", action.id, grant.profile_id, target.entity_id ), "action_target", @@ -482,11 +482,16 @@ pub fn explain_access(registry: &CompiledRegistry) -> AccessExplanation { missing_claims: "missing required direct claims cannot satisfy their row boundary or verified-claim lookup; types and scalar/set shape are listed in claimContract", evaluation: "configuration inspection only; credentials and record access are not evaluated", routes: registry.access().clone(), - entities: registry.entities().values().map(|entity| EntityAccessExplanation { - entity: entity.id.clone(), classification: entity.classification, - requirements: entity.access_requirements.clone(), - profiles: entity.access_profiles.values().cloned().collect(), - }).collect(), + entities: registry + .entities() + .values() + .map(|entity| EntityAccessExplanation { + entity: entity.id.clone(), + classification: entity.classification, + requirements: entity.access_requirements.clone(), + profiles: entity.access_profiles.values().cloned().collect(), + }) + .collect(), actions: registry.actions().clone(), row_reach: row_reach(registry.entities(), registry.actions()), claim_contract, diff --git a/crates/registry-breg/src/action_evidence_config.rs b/crates/registry-breg/src/action_evidence_config.rs index 670c83baa0..e5d1c23b0c 100644 --- a/crates/registry-breg/src/action_evidence_config.rs +++ b/crates/registry-breg/src/action_evidence_config.rs @@ -121,7 +121,7 @@ mod tests { project.actions[0].evidence.clear(); project.evidence_providers.clear(); for profile in &mut project.access_profiles { - profile.grants.retain(|grant| { + profile.permissions.retain(|grant| { grant .action .as_ref() diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 09b6bae52b..9f06a9fe56 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -99,6 +99,9 @@ fn authorize_action<'a>( options: &QueryOptions, ) -> Option> { service.mutations.as_ref()?; + if claims.grant().is_some() { + return None; + } let inventory = service.registry.actions(); // A caller cannot construct an alternate path, operation or action under a // valid route identifier. Discovery and dispatch use this same inventory. @@ -124,7 +127,7 @@ fn authorize_action<'a>( .iter() .find(|action| action.id == route.action_id)?; let grant = action - .grants + .permissions .iter() .find(|grant| grant.profile_id == selected)?; if grant.anonymous @@ -141,6 +144,27 @@ fn authorize_action<'a>( { return None; } + if claims.actor_kind().is_some() && grant.actor_kind.is_none() { + return None; + } + if let Some(expected) = grant.actor_kind { + let actual = claims.actor_kind()?; + if !matches!( + (expected, actual), + (crate::contract::ActorKindSource::Human, registry_platform_oidc::ActorKind::Human) + | (crate::contract::ActorKindSource::Agent, registry_platform_oidc::ActorKind::Agent) + | (crate::contract::ActorKindSource::Service, registry_platform_oidc::ActorKind::Service) + ) { + return None; + } + } + if !grant.requester_clients.is_empty() + && !claims + .requester_client() + .is_some_and(|client| grant.requester_clients.contains(client)) + { + return None; + } let principal = claims.principal()?; let target_authority = grant .targets diff --git a/crates/registry-breg/src/api/context.rs b/crates/registry-breg/src/api/context.rs index e6eb7bd453..968fa8ae4a 100644 --- a/crates/registry-breg/src/api/context.rs +++ b/crates/registry-breg/src/api/context.rs @@ -5,6 +5,9 @@ use std::fmt; use crate::contract::Operation; use crate::model::HttpMethod; +use crate::task_grant::TaskGrantBinding; +use registry_platform_oidc::{ActorKind, GrantClaims}; +use serde_json::Value; const MAX_DIRECT_VALUE_BYTES: usize = 512; const MAX_STRING_SET_VALUES: usize = 64; @@ -77,6 +80,11 @@ pub struct VerifiedRequestClaims { scopes: BTreeSet, purpose: Option, direct_claims: BTreeMap, + actor_kind: Option, + requester_client: Option, + actor_subject: Option, + grant: Option, + grant_subjects: BTreeMap, } impl VerifiedRequestClaims { @@ -99,9 +107,30 @@ impl VerifiedRequestClaims { scopes, purpose, direct_claims, + actor_kind: None, + requester_client: None, + actor_subject: None, + grant: None, + grant_subjects: BTreeMap::new(), }) } + pub(crate) fn with_contextual_authority( + mut self, + actor_kind: Option, + requester_client: Option, + actor_subject: Option, + grant: Option, + grant_subjects: BTreeMap, + ) -> Result { + self.actor_kind = actor_kind; + self.requester_client = requester_client.map(validate_value).transpose()?; + self.actor_subject = actor_subject.map(validate_value).transpose()?; + self.grant = grant; + self.grant_subjects = grant_subjects; + Ok(self) + } + #[must_use] pub fn anonymous() -> Self { Self { @@ -110,6 +139,11 @@ impl VerifiedRequestClaims { scopes: BTreeSet::new(), purpose: None, direct_claims: BTreeMap::new(), + actor_kind: None, + requester_client: None, + actor_subject: None, + grant: None, + grant_subjects: BTreeMap::new(), } } @@ -132,6 +166,22 @@ impl VerifiedRequestClaims { pub(crate) fn direct_claim(&self, name: &str) -> Option<&VerifiedClaimValue> { self.direct_claims.get(name) } + + pub(crate) fn actor_kind(&self) -> Option { + self.actor_kind + } + pub(crate) fn requester_client(&self) -> Option<&str> { + self.requester_client.as_deref() + } + pub(crate) fn actor_subject(&self) -> Option<&str> { + self.actor_subject.as_deref() + } + pub(crate) fn grant(&self) -> Option<&GrantClaims> { + self.grant.as_ref() + } + pub(crate) fn grant_subjects(&self) -> &BTreeMap { + &self.grant_subjects + } } impl fmt::Debug for VerifiedRequestClaims { @@ -143,6 +193,16 @@ impl fmt::Debug for VerifiedRequestClaims { .field("scope_count", &self.scopes.len()) .field("purpose", &self.purpose.as_ref().map(|_| "")) .field("direct_claims", &self.direct_claims.keys()) + .field("actor_kind", &self.actor_kind) + .field( + "requester_client", + &self.requester_client.as_ref().map(|_| ""), + ) + .field( + "actor_subject", + &self.actor_subject.as_ref().map(|_| ""), + ) + .field("task_grant", &self.grant.as_ref().map(|_| "")) .finish() } } @@ -209,6 +269,7 @@ pub struct AuthorizedRequestContext { request_actions: Vec, request_presence: Vec, submitter_targets: BTreeMap>, + task_grant: Option, } impl AuthorizedRequestContext { @@ -226,6 +287,7 @@ impl AuthorizedRequestContext { request_actions: Vec::new(), request_presence: Vec::new(), submitter_targets: BTreeMap::new(), + task_grant: None, } } @@ -237,6 +299,15 @@ impl AuthorizedRequestContext { self } + pub(crate) fn with_task_grant(mut self, grant: Option) -> Self { + self.task_grant = grant; + self + } + + pub fn task_grant(&self) -> Option<&TaskGrantBinding> { + self.task_grant.as_ref() + } + pub(crate) fn submitter_targets(&self) -> &BTreeMap> { &self.submitter_targets } @@ -292,6 +363,10 @@ impl fmt::Debug for AuthorizedRequestContext { .field("row_boundaries", &self.row_boundaries) .field("request_action_count", &self.request_actions.len()) .field("request_presence_count", &self.request_presence.len()) + .field( + "task_grant", + &self.task_grant.as_ref().map(|_| ""), + ) .finish() } } diff --git a/crates/registry-breg/src/api/gis.rs b/crates/registry-breg/src/api/gis.rs index df37e2e656..7602aec310 100644 --- a/crates/registry-breg/src/api/gis.rs +++ b/crates/registry-breg/src/api/gis.rs @@ -1162,7 +1162,7 @@ accessProfiles: default: true principalClaim: principal requiredScopes: [registry:sites:read] - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] @@ -1173,14 +1173,14 @@ accessProfiles: maximumLatitudeSpanDegrees: 2 - id: get-only principalClaim: principal - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get] readableFields: [code, label, location] - id: no-bbox principalClaim: principal - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index b8d17e524c..b572e37774 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -2035,7 +2035,7 @@ fn request_action_target_authority( plan.target_entities .iter() .map(|target_entity_id| { - let grant = plan.review_grants.iter().find(|grant| { + let grant = plan.review_permissions.iter().find(|grant| { grant.profile_id == context.selected_profile() && grant.stage == stage && grant.target_entity_id == *target_entity_id @@ -2055,7 +2055,7 @@ fn request_action_target_authority( .target_entities .iter() .map(|target_entity_id| { - let grant = plan.apply_grants.iter().find(|grant| { + let grant = plan.apply_permissions.iter().find(|grant| { grant.profile_id == context.selected_profile() && grant.target_entity_id == *target_entity_id })?; @@ -2084,7 +2084,7 @@ fn request_automatic_apply_authority( plan.target_entities .iter() .map(|target_entity_id| { - let grant = plan.apply_grants.iter().find(|grant| { + let grant = plan.apply_permissions.iter().find(|grant| { grant.profile_id == selected_profile && grant.target_entity_id == *target_entity_id })?; Some(RequestActionTargetAuthority { @@ -2163,7 +2163,7 @@ fn request_visibility_authority( None } else { let grant = if route.operation == Operation::ApplyRequest { - plan.apply_grants + plan.apply_permissions .iter() .find(|grant| { grant.profile_id == selected_profile @@ -2171,7 +2171,7 @@ fn request_visibility_authority( }) .map(|grant| (BTreeSet::new(), &grant.row_boundaries)) } else { - plan.review_grants + plan.review_permissions .iter() .find(|grant| { grant.profile_id == selected_profile @@ -2242,7 +2242,7 @@ fn request_visibility_authority( .get(&grant.request_type)? .change_request .as_ref()?; - if !plan.presence_grants.iter().any(|compiled| { + if !plan.presence_permissions.iter().any(|compiled| { compiled.profile_id == selected_profile && compiled.target_entity_id == entity.id && compiled.request_row_boundaries == grant.row_boundaries @@ -2537,7 +2537,8 @@ fn authorize_direct_route_base<'a>( claims.purpose().map(str::to_owned), selected_profile.to_owned(), row_boundaries, - ); + ) + .with_task_grant(task_grant_binding(profile, claims).ok()?); let submitter_targets = profile .submitter_targets .iter() @@ -2632,7 +2633,8 @@ fn authorize_read_path_route<'a>( claims.purpose().map(str::to_owned), selected_profile.to_owned(), row_boundaries, - ), + ) + .with_task_grant(task_grant_binding(profile, claims).ok()?), readable_fields, read_path: Some(read_path), }) @@ -2770,9 +2772,106 @@ pub(crate) fn authorize_profile_claims( { return Err("purpose_missing_or_not_allowed"); } + if claims.actor_kind().is_some() && profile.actor_kind.is_none() { + return Err("actor_bound_profile_required"); + } + if let Some(expected) = profile.actor_kind { + let actual = claims.actor_kind().ok_or("actor_kind_missing")?; + let matches = matches!( + (expected, actual), + ( + crate::contract::ActorKindSource::Human, + registry_platform_oidc::ActorKind::Human + ) | ( + crate::contract::ActorKindSource::Agent, + registry_platform_oidc::ActorKind::Agent + ) | ( + crate::contract::ActorKindSource::Service, + registry_platform_oidc::ActorKind::Service + ) + ); + if !matches { + return Err("actor_kind_mismatched"); + } + } + if !profile.requester_clients.is_empty() + && !claims + .requester_client() + .is_some_and(|client| profile.requester_clients.contains(client)) + { + return Err("requester_client_missing_or_mismatched"); + } + match (&profile.task_grant, claims.grant()) { + (Some(_), None) => return Err("task_grant_missing"), + (None, Some(_)) => return Err("task_grant_profile_required"), + (None, None) + if profile.actor_kind == Some(crate::contract::ActorKindSource::Agent) + && claims.actor_subject().is_none() => + { + return Err("trusted_actor_missing") + } + _ => {} + } verified_row_boundaries(profile, claims).ok_or("row_claim_missing_or_wrong_cardinality") } +fn task_grant_binding( + profile: &AccessProfileSource, + claims: &VerifiedRequestClaims, +) -> Result, &'static str> { + let Some(expected) = &profile.task_grant else { + return Ok(None); + }; + let grant = claims.grant().ok_or("task_grant_missing")?; + if grant.authority() != expected.authority + || grant.source_issuer() != expected.source_issuer + || Some(grant.client()) != claims.requester_client() + || !profile.required_purposes.contains(grant.purpose()) + { + return Err("task_grant_binding_mismatched"); + } + let actual = grant + .bounds() + .breg_permissions() + .ok_or("task_grant_bounds_wrong_type")?; + let actual = actual + .iter() + .map(|permission| { + ( + permission.collection().to_owned(), + permission + .operations() + .iter() + .cloned() + .collect::>(), + ) + }) + .collect::>(); + let expected_bounds = expected + .permissions + .iter() + .map(|permission| { + let operations = permission + .operations + .iter() + .map(|operation| { + serde_json::to_value(operation) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + }) + .collect::>>()?; + Some((permission.collection.clone(), operations)) + }) + .collect::>>() + .ok_or("task_grant_bounds_invalid")?; + if actual.len() != expected.permissions.len() || actual != expected_bounds { + return Err("task_grant_bounds_mismatched"); + } + crate::task_grant::TaskGrantBinding::from_verified(grant, claims.grant_subjects().clone()) + .map(Some) + .map_err(|_| "task_grant_invalid") +} + fn verified_row_boundaries( profile: &AccessProfileSource, claims: &VerifiedRequestClaims, @@ -4209,7 +4308,7 @@ fn lookup_request_values( fn lookup_verified_claim_values( entity: &CompiledEntity, selector: &crate::model::CompiledSelectorProfile, - grant: &crate::contract::LookupGrantSource, + grant: &crate::contract::LookupPermissionSource, claims: &VerifiedRequestClaims, ) -> Result, LookupResolutionError> { selector @@ -4314,7 +4413,7 @@ fn filtered_schema( .is_some_and(|field| readable_api_names.contains(field)) }); } - // Generated authoring artifacts contain complete effects and grants. The + // Generated authoring artifacts contain complete effects and permissions. The // served schema is a caller projection, so it must not copy that authority // inventory or identify request types hidden from the selected context. if let Some(control) = object.get_mut("x-registry-changeControl") { diff --git a/crates/registry-breg/src/api/tests/change_request_action_tests.rs b/crates/registry-breg/src/api/tests/change_request_action_tests.rs index 07c80b7f74..51847e12c4 100644 --- a/crates/registry-breg/src/api/tests/change_request_action_tests.rs +++ b/crates/registry-breg/src/api/tests/change_request_action_tests.rs @@ -316,7 +316,7 @@ fn compiled_registry() -> Arc { } }], "accessProfiles":[{ - "id":"request-reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"request-reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","submit_request","approve_request","reject_request","request_revision"], "readableFields":["placement","proposed-site"], @@ -331,7 +331,7 @@ fn compiled_registry() -> Arc { "rowBoundaries": [] }] },{ - "id":"request-applier","principalClaim":"principal","grants":[{ + "id":"request-applier","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["placement"], diff --git a/crates/registry-breg/src/api/tests/change_request_read_tests.rs b/crates/registry-breg/src/api/tests/change_request_read_tests.rs index 54e4856a43..5811ca7570 100644 --- a/crates/registry-breg/src/api/tests/change_request_read_tests.rs +++ b/crates/registry-breg/src/api/tests/change_request_read_tests.rs @@ -243,7 +243,7 @@ fn compiled_registry() -> Arc { } }], "accessProfiles":[{ - "id":"request-reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"request-reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","approve_request","reject_request","request_revision"], "readableFields":["placement","proposed-site"], @@ -265,7 +265,7 @@ fn compiled_registry() -> Arc { "rowBoundaries": [] }] },{ - "id":"request-submitter","principalClaim":"principal","grants":[{ + "id":"request-submitter","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","create","patch","submit_request"], "readableFields":["placement","proposed-site"], @@ -273,7 +273,7 @@ fn compiled_registry() -> Arc { "rowBoundaries": [] }] },{ - "id":"request-applier","principalClaim":"principal","grants":[{ + "id":"request-applier","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["placement"], @@ -281,7 +281,7 @@ fn compiled_registry() -> Arc { "rowBoundaries": [] }] },{ - "id":"placement-viewer","principalClaim":"principal","grants":[{ + "id":"placement-viewer","principalClaim":"principal","permissions":[{ "entity":"placement", "operations":["get"], "readableFields":["site"], diff --git a/crates/registry-breg/src/api/tests/immediate_action_tests.rs b/crates/registry-breg/src/api/tests/immediate_action_tests.rs index 70e019a934..a040125348 100644 --- a/crates/registry-breg/src/api/tests/immediate_action_tests.rs +++ b/crates/registry-breg/src/api/tests/immediate_action_tests.rs @@ -4,6 +4,7 @@ use super::*; use crate::compiler::{compile_project, CompileProfile}; use crate::contract::parse_project_yaml; use crate::cursor::CursorCodec; +use crate::auth::{AuthorityClaimConfig, RegistryAuthenticator}; use crate::model::{CompiledAction, CompiledRegistry}; use crate::postgres::{ ConnectionConfig, ExpectedRegistryIdentity, PoolBounds, PostgresRecordMutationService, @@ -11,6 +12,9 @@ use crate::postgres::{ }; use std::time::{Duration, Instant}; use zeroize::Zeroizing; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 @@ -41,7 +45,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [case.rename] requiredPurposes: [case-management] - grants: + permissions: - action: rename-case operations: [invoke] targets: @@ -52,7 +56,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [case.supervise] requiredPurposes: [case-management] - grants: + permissions: - action: rename-case operations: [invoke] targets: [{entity: case, rowBoundaries: []}] @@ -66,6 +70,55 @@ fn compiled() -> Arc { ) } +#[tokio::test] +async fn signed_task_token_cannot_discover_or_invoke_an_ordinary_immediate_action() { + let registry = compiled(); + let idp = MockIdp::start().await; + let audience = "urn:example:breg"; + let mut verifier = oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]); + verifier.allowed_clients = vec!["agent-client".to_owned()]; + let auth = RegistryAuthenticator::new( + ®istry, + verifier, + Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + AuthorityClaimConfig::new( + "registry_principal", + Some("registry_purpose".to_owned()), + ), + ) + .expect("action verifier config is valid"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let token = idp.mint_token(json!({ + "aud": audience, + "sub": "citizen-sub", + "azp": "agent-client", + "registry_principal": "citizen-sub", + "registry_actor_kind": "agent", + "registry_purpose": "case-management", + "registry_grant_id": "00000000-0000-4000-8000-0000000000bb", + "registry_grant_authority": "casework-v1", + "registry_grant_source_issuer": "https://casework.example", + "registry_grant_client": "agent-client", + "registry_grant_resource": audience, + "registry_grant_exp": now + 600, + "registry_grant_bounds": {"type":"breg","permissions":[{"collection":"cases","operations":["get"]}]}, + "identity": {"tenant": "tenant-a"}, + "scope": "case.rename" + })); + let claims = auth.authenticate(&token).await.expect("signed task token verifies"); + let service = service_for(registry.clone(), true); + let route = ®istry.actions().routes[0]; + assert!(authorize_action(&service, route, &claims, &QueryOptions::default()).is_none()); + assert!(visible_actions(&service, &claims, &QueryOptions::default()).is_empty()); +} + #[test] fn invocation_uses_public_names_and_a_closed_typed_envelope() { let registry = compiled(); diff --git a/crates/registry-breg/src/artifacts.rs b/crates/registry-breg/src/artifacts.rs index 3e741699ce..38346fe5a0 100644 --- a/crates/registry-breg/src/artifacts.rs +++ b/crates/registry-breg/src/artifacts.rs @@ -820,15 +820,15 @@ fn render_change_request( "inputSchema": openapi_input_schema_id(&entity.id, action.operation.access_operation()), "responseSchema": "ChangeRequestActionResponse", })).collect::>(), - "reviewGrants": request.review_grants, - "applyGrants": request.apply_grants, - "presenceGrants": request.presence_grants, + "reviewPermissions": request.review_permissions, + "applyPermissions": request.apply_permissions, + "presencePermissions": request.presence_permissions, "targetEntities": request.target_entities, }) } /// Source-free change-request capability projection for caller-filtered -/// Registry metadata. It intentionally omits effects, grants, targets and all +/// Registry metadata. It intentionally omits effects, permissions, targets and all /// planner provenance, so this descriptive surface cannot manufacture action /// authority or disclose hidden configuration. #[allow(dead_code)] // Called by the production /v1/registry metadata route. @@ -1127,7 +1127,7 @@ fn action_response_result_shapes( return vec![known_action_result_effects(action, results)]; } let mut shapes = action - .grants + .permissions .iter() .map(|grant| known_action_result_effects(action, &grant.results)) .collect::>(); @@ -1366,7 +1366,7 @@ fn selected_profile_from_access_profiles( fn action_profile_ids(action: &CompiledAction) -> Vec { action - .grants + .permissions .iter() .map(|grant| grant.profile_id.clone()) .collect::>() @@ -1380,7 +1380,7 @@ fn action_selected_result_effects( ) -> BTreeSet { match selected_profile { Some(profile) => action - .grants + .permissions .iter() .filter(|grant| grant.profile_id == profile) .flat_map(|grant| grant.results.iter().cloned()) @@ -1530,7 +1530,7 @@ fn action_operation_security( let mut requires_bearer = false; for profile in &profiles { let Some(grant) = action - .grants + .permissions .iter() .find(|grant| grant.profile_id == *profile) else { @@ -2261,7 +2261,7 @@ fn request_action_target_entities(spec: OpenApiOperationSpec<'_>) -> Vec if let Some(stage) = spec.route.request_stage.as_deref() { targets.extend( request - .review_grants + .review_permissions .iter() .filter(|grant| grant.profile_id == profile && grant.stage == stage) .map(|grant| grant.target_entity_id.clone()), @@ -2271,7 +2271,7 @@ fn request_action_target_entities(spec: OpenApiOperationSpec<'_>) -> Vec Operation::ApplyRequest => { targets.extend( request - .apply_grants + .apply_permissions .iter() .filter(|grant| grant.profile_id == profile) .map(|grant| grant.target_entity_id.clone()), @@ -4702,9 +4702,9 @@ mod spatial_tests { ] }], "accessProfiles":[ - {"id":"map","default":true,"principalClaim":"principal","grants":[{"entity":"site","operations":["get","list"],"readableFields":["code","label","location"],"spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.5,"maximumLatitudeSpanDegrees":0.25}}, "rowBoundaries": []}]}, - {"id":"plain","principalClaim":"principal","grants":[{"entity":"site","operations":["get","list"],"readableFields":["code"], "rowBoundaries": []}]}, - {"id":"geometry-only","principalClaim":"principal","grants":[{"entity":"site","operations":["get","list"],"readableFields":["location"], "rowBoundaries": []}]} + {"id":"map","default":true,"principalClaim":"principal","permissions":[{"entity":"site","operations":["get","list"],"readableFields":["code","label","location"],"spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.5,"maximumLatitudeSpanDegrees":0.25}}, "rowBoundaries": []}]}, + {"id":"plain","principalClaim":"principal","permissions":[{"entity":"site","operations":["get","list"],"readableFields":["code"], "rowBoundaries": []}]}, + {"id":"geometry-only","principalClaim":"principal","permissions":[{"entity":"site","operations":["get","list"],"readableFields":["location"], "rowBoundaries": []}]} ] }"#).expect("spatial artifact fixture parses"); compile_project(&project, &[], CompileProfile::Authoring) diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index e5b011dfca..1ce8b7d69c 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use std::time::{SystemTime, UNIX_EPOCH}; use axum::body::Body; use axum::extract::State; @@ -14,7 +15,8 @@ use axum::middleware::Next; use axum::response::Response; use registry_platform_authcommon::{parse_bearer_token, validate_compact_access_token}; use registry_platform_oidc::{ - is_access_token_typ_pair, Audience, JwksFetcher, OidcError, TokenVerifier, TokenVerifierConfig, + actor_kind, grant_claims, is_access_token_typ_pair, Audience, ClaimError, ClaimNames, + JwksFetcher, OidcError, TokenVerifier, TokenVerifierConfig, }; use serde_json::Value; use thiserror::Error; @@ -49,6 +51,8 @@ const REGISTERED_CLAIMS: &[&str] = &[ pub struct AuthorityClaimConfig { principal_claim: String, purpose_claim: Option, + contextual_claims: ClaimNames, + trusted_actors: BTreeMap, } impl AuthorityClaimConfig { @@ -57,8 +61,21 @@ impl AuthorityClaimConfig { Self { principal_claim: principal_claim.into(), purpose_claim, + contextual_claims: ClaimNames::default(), + trusted_actors: BTreeMap::new(), } } + + #[must_use] + pub fn with_contextual_claims( + mut self, + contextual_claims: ClaimNames, + trusted_actors: BTreeMap, + ) -> Self { + self.contextual_claims = contextual_claims; + self.trusted_actors = trusted_actors; + self + } } impl fmt::Debug for AuthorityClaimConfig { @@ -67,6 +84,8 @@ impl fmt::Debug for AuthorityClaimConfig { .debug_struct("AuthorityClaimConfig") .field("principal_claim", &self.principal_claim) .field("purpose_claim", &self.purpose_claim) + .field("contextual_claims", &self.contextual_claims) + .field("trusted_actor_clients", &self.trusted_actors.keys()) .finish() } } @@ -118,9 +137,12 @@ pub struct RegistryAuthenticator { verifier: TokenVerifier, last_key_refusal_warning: Mutex>, audience: String, + scope_claim: String, principal_claim: String, purpose_claim: Option, direct_claims: BTreeMap, + contextual_claims: ClaimNames, + trusted_actors: BTreeMap, } impl RegistryAuthenticator { @@ -135,13 +157,17 @@ impl RegistryAuthenticator { validate_verifier_profile(&verifier_config)?; let direct_claims = validate_claim_mapping(registry, &verifier_config, &claims)?; let audience = verifier_config.audiences[0].clone(); + let scope_claim = verifier_config.scope_claim.clone(); Ok(Self { verifier: TokenVerifier::new(verifier_config, key_source), last_key_refusal_warning: Mutex::new(None), audience, + scope_claim, principal_claim: claims.principal_claim, purpose_claim: claims.purpose_claim, direct_claims, + contextual_claims: claims.contextual_claims, + trusted_actors: claims.trusted_actors, }) } @@ -193,7 +219,8 @@ impl RegistryAuthenticator { } let scopes = verified .scopes - .into_iter() + .iter() + .cloned() .map(validate_scope) .collect::, _>>()?; let direct_claims = self @@ -211,6 +238,58 @@ impl RegistryAuthenticator { }) .collect::, _>>()?; + let actor_kind = match actor_kind(&verified.claims, &self.contextual_claims) { + Ok(kind) => Some(kind), + Err(ClaimError::Missing(_)) => None, + Err(_) => return Err(AuthenticationError::InvalidClaims), + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AuthenticationError::InvalidClaims)? + .as_secs(); + let grant = grant_claims(&verified.claims, &self.contextual_claims, now) + .map_err(|_| AuthenticationError::InvalidClaims)?; + if let Some(grant) = &grant { + grant + .verify_context(&verified, &self.audience) + .map_err(|_| AuthenticationError::InvalidClaims)?; + } + let requester_client = verified + .matched_client_id() + .map_err(|_| AuthenticationError::InvalidClaims)? + .map(str::to_owned); + let actor_subject = optional_actor_subject(claims.get("act"))?; + if let Some(actor) = actor_subject.as_deref() { + let client = requester_client + .as_deref() + .ok_or(AuthenticationError::InvalidClaims)?; + if self.trusted_actors.get(client).map(String::as_str) != Some(actor) { + return Err(AuthenticationError::InvalidClaims); + } + } + let grant_subjects = if grant.is_some() { + bounded_identity(claims.get("identity"))? + } else { + BTreeMap::new() + }; + let direct_claims = if grant.is_some() { + self.direct_claims + .iter() + .filter_map(|(name, expectation)| { + let value = if name == "sub" { + subject.as_ref() + } else { + grant_subjects.get(name) + }; + value.map(|value| { + mapped_claim(value, expectation).map(|value| (name.clone(), value)) + }) + }) + .collect::, _>>()? + } else { + direct_claims + }; + VerifiedRequestClaims::authenticated( self.principal_claim.clone(), principal, @@ -218,6 +297,15 @@ impl RegistryAuthenticator { purpose, direct_claims, ) + .and_then(|claims| { + claims.with_contextual_authority( + actor_kind, + requester_client, + actor_subject, + grant, + grant_subjects, + ) + }) .map_err(|_| AuthenticationError::InvalidClaims) } } @@ -243,9 +331,12 @@ impl fmt::Debug for RegistryAuthenticator { .debug_struct("RegistryAuthenticator") .field("issuer", &"") .field("audience", &"") + .field("scope_claim", &self.scope_claim) .field("principal_claim", &self.principal_claim) .field("purpose_claim", &self.purpose_claim) .field("direct_claims", &self.direct_claims.keys()) + .field("contextual_claims", &self.contextual_claims) + .field("trusted_actor_clients", &self.trusted_actors.keys()) .finish() } } @@ -333,6 +424,22 @@ fn validate_claim_mapping( verifier: &TokenVerifierConfig, claims: &AuthorityClaimConfig, ) -> Result, AuthenticationConfigError> { + claims + .contextual_claims + .validate() + .map_err(|_| AuthenticationConfigError::InvalidClaimMapping)?; + if claims.trusted_actors.len() > 128 + || claims.trusted_actors.iter().any(|(client, actor)| { + !valid_config_value(client) + || client.chars().any(char::is_whitespace) + || !valid_config_value(actor) + || actor.chars().any(char::is_whitespace) + || uuid::Uuid::parse_str(actor).is_err() + || !verifier.allowed_clients.contains(client) + }) + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } if !(valid_authority_claim_name(&claims.principal_claim) || claims.principal_claim == "sub") || claims.principal_claim == verifier.scope_claim { @@ -372,6 +479,28 @@ fn validate_claim_mapping( AuthenticationConfigError::ConflictingClaimExpectation } })?; + for profile in registry + .entities() + .values() + .flat_map(|entity| entity.access_profiles.values()) + { + if profile + .requester_clients + .iter() + .any(|client| !verifier.allowed_clients.contains(client)) + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + if profile.actor_kind == Some(crate::contract::ActorKindSource::Agent) + && profile.task_grant.is_none() + && profile + .requester_clients + .iter() + .any(|client| !claims.trusted_actors.contains_key(client)) + { + return Err(AuthenticationConfigError::InvalidClaimMapping); + } + } if inventory .principal_claims .iter() @@ -475,6 +604,50 @@ fn optional_direct_string(value: Option<&Value>) -> Result, Authe } } +fn optional_actor_subject(value: Option<&Value>) -> Result, AuthenticationError> { + let Some(value) = value else { + return Ok(None); + }; + let object = value + .as_object() + .ok_or(AuthenticationError::InvalidClaims)?; + if object.len() != 1 { + return Err(AuthenticationError::InvalidClaims); + } + required_direct_string(object.get("sub")).map(Some) +} + +fn bounded_identity(value: Option<&Value>) -> Result, AuthenticationError> { + let object = value + .and_then(Value::as_object) + .ok_or(AuthenticationError::InvalidClaims)?; + if object.is_empty() || object.len() > 32 { + return Err(AuthenticationError::InvalidClaims); + } + object + .iter() + .map(|(name, value)| { + if name.is_empty() + || name.len() > MAX_CLAIM_NAME_BYTES + || name.chars().any(char::is_control) + || !match value { + Value::String(value) => { + !value.is_empty() + && value.len() <= MAX_SCOPE_VALUE_BYTES + && !value.chars().any(char::is_control) + } + Value::Bool(_) => true, + Value::Number(value) => value.as_i64().is_some() || value.as_u64().is_some(), + _ => false, + } + { + return Err(AuthenticationError::InvalidClaims); + } + Ok((name.clone(), value.clone())) + }) + .collect() +} + fn mapped_claim( value: &Value, expectation: &DirectClaimExpectation, diff --git a/crates/registry-breg/src/authority.rs b/crates/registry-breg/src/authority.rs index 1ef6901660..2b231ba946 100644 --- a/crates/registry-breg/src/authority.rs +++ b/crates/registry-breg/src/authority.rs @@ -148,7 +148,7 @@ pub fn authority_inventory( } } for action in ®istry.actions().actions { - for grant in &action.grants { + for grant in &action.permissions { inventory.profile( grant.anonymous, grant.principal_claim.as_deref(), diff --git a/crates/registry-breg/src/change_request.rs b/crates/registry-breg/src/change_request.rs index 1dbf38946d..9dd7c8bffe 100644 --- a/crates/registry-breg/src/change_request.rs +++ b/crates/registry-breg/src/change_request.rs @@ -17,13 +17,14 @@ use crate::diagnostics::Diagnostic; use crate::model::{ ChangeRequestOperation, CompiledChangeRequest, CompiledChangeRequestActionRoute, CompiledChangeRequestApplication, CompiledChangeRequestApplicationMode, - CompiledChangeRequestApplyGrant, CompiledChangeRequestDisposition, CompiledChangeRequestEffect, - CompiledChangeRequestMutation, CompiledChangeRequestPlanner, CompiledChangeRequestPlannerKind, - CompiledChangeRequestPlannerLimits, CompiledChangeRequestPlannerWrite, - CompiledChangeRequestPresenceGrant, CompiledChangeRequestReferenceSources, - CompiledChangeRequestRetentionMode, CompiledChangeRequestReviewGrant, - CompiledChangeRequestReviewMode, CompiledChangeRequestStage, CompiledChangeRequestTarget, - CompiledChangeRequestTargetBinding, CompiledChangeRequestValue, CompiledEntity, + CompiledChangeRequestApplyPermission, CompiledChangeRequestDisposition, + CompiledChangeRequestEffect, CompiledChangeRequestMutation, CompiledChangeRequestPlanner, + CompiledChangeRequestPlannerKind, CompiledChangeRequestPlannerLimits, + CompiledChangeRequestPlannerWrite, CompiledChangeRequestPresencePermission, + CompiledChangeRequestReferenceSources, CompiledChangeRequestRetentionMode, + CompiledChangeRequestReviewMode, CompiledChangeRequestReviewPermission, + CompiledChangeRequestStage, CompiledChangeRequestTarget, CompiledChangeRequestTargetBinding, + CompiledChangeRequestValue, CompiledEntity, }; /// Path to an entity, identified so a diagnostic can name which entity it concerns. @@ -607,7 +608,7 @@ pub(crate) fn compile_change_requests( } } } - compile_presence_grants(entities, &mut compiled, &mut errors); + compile_presence_permissions(entities, &mut compiled, &mut errors); if !errors.is_empty() { return Err(errors); @@ -802,9 +803,10 @@ fn compile_request_entity( (effects, changed_fields, target_entities, None) }; let actions = compile_action_routes(&stages); - let review_grants = - compile_review_grants(request_entity, &stages, &changed_fields, entities, errors); - let apply_grants = compile_apply_grants(request_entity, &target_entities, entities, errors); + let review_permissions = + compile_review_permissions(request_entity, &stages, &changed_fields, entities, errors); + let apply_permissions = + compile_apply_permissions(request_entity, &target_entities, entities, errors); if !request_entity .access_profiles .values() @@ -824,8 +826,8 @@ fn compile_request_entity( review_mode, &application, &stages, - &review_grants, - &apply_grants, + &review_permissions, + &apply_permissions, &target_entities, errors, ); @@ -834,8 +836,8 @@ fn compile_request_entity( entities, effects: &effects, stages: &stages, - review_grants: &review_grants, - apply_grants: &apply_grants, + review_permissions: &review_permissions, + apply_permissions: &apply_permissions, target_entities: &target_entities, review_mode, application: &application, @@ -852,9 +854,9 @@ fn compile_request_entity( effects, stages, actions, - review_grants, - apply_grants, - presence_grants: Vec::new(), + review_permissions, + apply_permissions, + presence_permissions: Vec::new(), target_entities, maximum_targets: MAX_CHANGE_REQUEST_TARGETS, maximum_field_mutations: MAX_CHANGE_REQUEST_FIELD_MUTATIONS, @@ -868,8 +870,8 @@ fn validate_automatic_apply_profile( review_mode: CompiledChangeRequestReviewMode, application: &CompiledChangeRequestApplication, stages: &[CompiledChangeRequestStage], - review_grants: &[CompiledChangeRequestReviewGrant], - apply_grants: &[CompiledChangeRequestApplyGrant], + review_permissions: &[CompiledChangeRequestReviewPermission], + apply_permissions: &[CompiledChangeRequestApplyPermission], target_entities: &BTreeSet, errors: &mut Vec, ) { @@ -891,7 +893,7 @@ fn validate_automatic_apply_profile( profile.operations.contains(&Operation::ApproveRequest) && final_stage.is_some_and(|stage| { target_entities.iter().all(|target_entity_id| { - review_grants.iter().any(|grant| { + review_permissions.iter().any(|grant| { grant.profile_id == profile.id && grant.stage == stage && grant.target_entity_id == *target_entity_id @@ -902,7 +904,7 @@ fn validate_automatic_apply_profile( }; can_trigger_ready && target_entities.iter().all(|target_entity_id| { - apply_grants.iter().any(|grant| { + apply_permissions.iter().any(|grant| { grant.profile_id == profile.id && grant.target_entity_id == *target_entity_id }) }) @@ -1507,18 +1509,18 @@ fn compile_action_routes( actions } -fn compile_review_grants( +fn compile_review_permissions( request_entity: &CompiledEntity, stages: &[CompiledChangeRequestStage], changed_fields: &BTreeMap>, entities: &BTreeMap, errors: &mut Vec, -) -> Vec { +) -> Vec { let stage_ids = stages .iter() .map(|stage| stage.id.as_str()) .collect::>(); - let mut grants = Vec::new(); + let mut permissions = Vec::new(); for profile in request_entity.access_profiles.values() { let profile_base = profile_path(&request_entity.id, &profile.id); for grant in &profile.review_stages { @@ -1533,58 +1535,59 @@ fn compile_review_grants( errors.push(Diagnostic::error( "change_request.review_stage.operation_required", format!("{profile_base}.operations"), - "review stage grants require approve_request, reject_request, or request_revision authority", + "review stage permissions require approve_request, reject_request, or request_revision authority", )); } - let stage_grant_path = format!("{profile_base}.reviewStages[stage={}]", grant.stage); + let stage_permission_path = + format!("{profile_base}.reviewStages[stage={}]", grant.stage); if !stage_ids.contains(grant.stage.as_str()) { errors.push(Diagnostic::error( "change_request.review_stage.unknown", - format!("{stage_grant_path}.stage"), - "a review grant refers to an unknown review stage", + format!("{stage_permission_path}.stage"), + "a review permission refers to an unknown review stage", )); continue; } for target in &grant.targets { - let target_grant_path = - format!("{stage_grant_path}.targets[entity={}]", target.entity); + let target_permission_path = + format!("{stage_permission_path}.targets[entity={}]", target.entity); let Some(target_entity) = entities.get(&target.entity) else { errors.push(Diagnostic::error( "change_request.review_stage.target_unknown", - format!("{target_grant_path}.entity"), - "a review grant targets an unknown entity", + format!("{target_permission_path}.entity"), + "a review permission targets an unknown entity", )); continue; }; validate_target_fields( target_entity, &target.readable_fields, - &format!("{target_grant_path}.readableFields"), + &format!("{target_permission_path}.readableFields"), errors, ); validate_row_boundaries( target_entity, &target.row_boundaries, - &format!("{target_grant_path}.rowBoundaries"), + &format!("{target_permission_path}.rowBoundaries"), errors, ); - validate_grant_access_requirements( + validate_permission_access_requirements( target_entity, profile, &target.row_boundaries, - &target_grant_path, + &target_permission_path, errors, ); if let Some(required) = changed_fields.get(&target.entity) { if !required.is_subset(&target.readable_fields) { errors.push(Diagnostic::error( "change_request.review_projection.incomplete", - format!("{target_grant_path}.readableFields"), + format!("{target_permission_path}.readableFields"), "review target projections must cover every changed target field", )); } } - grants.push(CompiledChangeRequestReviewGrant { + permissions.push(CompiledChangeRequestReviewPermission { profile_id: profile.id.clone(), stage: grant.stage.clone(), target_entity_id: target.entity.clone(), @@ -1629,23 +1632,23 @@ fn compile_review_grants( )); } } - grants.sort_by(|left, right| { + permissions.sort_by(|left, right| { (&left.stage, &left.profile_id, &left.target_entity_id).cmp(&( &right.stage, &right.profile_id, &right.target_entity_id, )) }); - grants + permissions } -fn compile_apply_grants( +fn compile_apply_permissions( request_entity: &CompiledEntity, target_entities: &BTreeSet, entities: &BTreeMap, errors: &mut Vec, -) -> Vec { - let mut grants = Vec::new(); +) -> Vec { + let mut permissions = Vec::new(); for profile in request_entity.access_profiles.values() { let profile_base = profile_path(&request_entity.id, &profile.id); if !profile.apply_targets.is_empty() @@ -1654,34 +1657,34 @@ fn compile_apply_grants( errors.push(Diagnostic::error( "change_request.apply_target.operation_required", format!("{profile_base}.operations"), - "apply target grants require apply_request authority", + "apply target permissions require apply_request authority", )); } for target in &profile.apply_targets { - let target_grant_path = + let target_permission_path = format!("{profile_base}.applyTargets[entity={}]", target.entity); let Some(target_entity) = entities.get(&target.entity) else { errors.push(Diagnostic::error( "change_request.apply_target.unknown", - format!("{target_grant_path}.entity"), - "an apply grant targets an unknown entity", + format!("{target_permission_path}.entity"), + "an apply permission targets an unknown entity", )); continue; }; validate_row_boundaries( target_entity, &target.row_boundaries, - &format!("{target_grant_path}.rowBoundaries"), + &format!("{target_permission_path}.rowBoundaries"), errors, ); - validate_grant_access_requirements( + validate_permission_access_requirements( target_entity, profile, &target.row_boundaries, - &target_grant_path, + &target_permission_path, errors, ); - grants.push(CompiledChangeRequestApplyGrant { + permissions.push(CompiledChangeRequestApplyPermission { profile_id: profile.id.clone(), target_entity_id: target.entity.clone(), row_boundaries: target.row_boundaries.clone(), @@ -1707,14 +1710,14 @@ fn compile_apply_grants( "at least one profile must be able to apply the complete change-request target set", )); } - grants.sort_by(|left, right| { + permissions.sort_by(|left, right| { (&left.profile_id, &left.target_entity_id) .cmp(&(&right.profile_id, &right.target_entity_id)) }); - grants + permissions } -fn compile_presence_grants( +fn compile_presence_permissions( entities: &BTreeMap, plans: &mut BTreeMap, errors: &mut Vec, @@ -1735,7 +1738,7 @@ fn compile_presence_grants( errors.push(Diagnostic::error( "change_request.presence.request_type_unknown", format!("{presence_path}.requestType"), - "a request-presence grant refers to an unknown request type", + "a request-presence permission refers to an unknown request type", )); continue; }; @@ -1748,7 +1751,7 @@ fn compile_presence_grants( &format!("{presence_path}.rowBoundaries"), errors, ); - validate_grant_access_requirements( + validate_permission_access_requirements( request_entity, profile, &grant.row_boundaries, @@ -1759,7 +1762,7 @@ fn compile_presence_grants( errors.push(Diagnostic::error( "change_request.presence.target_unaffected", format!("{presence_path}.requestType"), - "a request-presence grant must name a request type that can affect the granted target entity", + "a request-presence permission must name a request type that can affect the granted target entity", )); continue; } @@ -1813,8 +1816,8 @@ fn compile_presence_grants( } } if let Some(plan) = plans.get_mut(&grant.request_type) { - plan.presence_grants - .push(CompiledChangeRequestPresenceGrant { + plan.presence_permissions + .push(CompiledChangeRequestPresencePermission { profile_id: profile.id.clone(), target_entity_id: target_entity.id.clone(), request_row_boundaries: grant.row_boundaries.clone(), @@ -1824,14 +1827,14 @@ fn compile_presence_grants( } } for plan in plans.values_mut() { - plan.presence_grants.sort_by(|left, right| { + plan.presence_permissions.sort_by(|left, right| { (&left.target_entity_id, &left.profile_id) .cmp(&(&right.target_entity_id, &right.profile_id)) }); } } -fn validate_grant_access_requirements( +fn validate_permission_access_requirements( entity: &CompiledEntity, profile: &AccessProfileSource, row_boundaries: &[RowBoundarySource], @@ -1857,9 +1860,9 @@ fn validate_target_fields( for field in fields { if !entity.fields.contains_key(field) && !entity.attachments.contains_key(field) { errors.push(Diagnostic::error( - "change_request.grant.field_unknown", + "change_request.permission.field_unknown", path, - "a change-request grant refers to an unknown target field or attachment slot", + "a change-request permission refers to an unknown target field or attachment slot", )); } } @@ -1881,9 +1884,9 @@ fn validate_row_boundaries( )) { errors.push(Diagnostic::error( - "change_request.grant.row_boundary_invalid", + "change_request.permission.row_boundary_invalid", path, - "change-request grant row boundaries must be direct, non-empty, and duplicate-free", + "change-request permission row boundaries must be direct, non-empty, and duplicate-free", )); } if boundary.field == "id" { @@ -1891,9 +1894,9 @@ fn validate_row_boundaries( } let Some(field) = entity.fields.get(&boundary.field) else { errors.push(Diagnostic::error( - "change_request.grant.row_boundary_field_unknown", + "change_request.permission.row_boundary_field_unknown", path, - "a change-request grant row boundary refers to an unknown field", + "a change-request permission row boundary refers to an unknown field", )); continue; }; @@ -1902,7 +1905,7 @@ fn validate_row_boundaries( FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } ) { errors.push(Diagnostic::error( - "change_request.grant.row_boundary_type_unsupported", + "change_request.permission.row_boundary_type_unsupported", path, "CRS84 point and structured fields cannot be change-request row-boundary fields", )); @@ -2014,8 +2017,8 @@ struct ContractFingerprintInput<'a> { entities: &'a BTreeMap, effects: &'a [CompiledChangeRequestEffect], stages: &'a [CompiledChangeRequestStage], - review_grants: &'a [CompiledChangeRequestReviewGrant], - apply_grants: &'a [CompiledChangeRequestApplyGrant], + review_permissions: &'a [CompiledChangeRequestReviewPermission], + apply_permissions: &'a [CompiledChangeRequestApplyPermission], target_entities: &'a BTreeSet, review_mode: CompiledChangeRequestReviewMode, application: &'a CompiledChangeRequestApplication, @@ -2028,8 +2031,8 @@ fn contract_fingerprint(input: ContractFingerprintInput<'_>) -> String { entities, effects, stages, - review_grants, - apply_grants, + review_permissions, + apply_permissions, target_entities, review_mode, application, @@ -2051,16 +2054,16 @@ fn contract_fingerprint(input: ContractFingerprintInput<'_>) -> String { "stages": stages, "reviewAuthority": authority_payload( request_entity, - review_grants.iter().map(|grant| grant.profile_id.as_str()).collect(), + review_permissions.iter().map(|grant| grant.profile_id.as_str()).collect(), [Operation::ApproveRequest, Operation::RejectRequest, Operation::RequestRevision] ), - "reviewGrants": review_grant_payload(review_grants), + "reviewPermissions": review_permission_payload(review_permissions), "applyAuthority": authority_payload( request_entity, - apply_grants.iter().map(|grant| grant.profile_id.as_str()).collect(), + apply_permissions.iter().map(|grant| grant.profile_id.as_str()).collect(), [Operation::ApplyRequest] ), - "applyGrants": apply_grant_payload(apply_grants), + "applyPermissions": apply_permission_payload(apply_permissions), "limits": { "maximumTargets": MAX_CHANGE_REQUEST_TARGETS, "maximumFieldMutations": MAX_CHANGE_REQUEST_FIELD_MUTATIONS, @@ -2149,7 +2152,9 @@ fn authority_payload( json!(profiles) } -fn review_grant_payload(grants: &[CompiledChangeRequestReviewGrant]) -> Vec { +fn review_permission_payload( + grants: &[CompiledChangeRequestReviewPermission], +) -> Vec { let mut grants = grants.iter().collect::>(); grants.sort_by(|left, right| { (&left.profile_id, &left.stage, &left.target_entity_id).cmp(&( @@ -2172,7 +2177,9 @@ fn review_grant_payload(grants: &[CompiledChangeRequestReviewGrant]) -> Vec Vec { +fn apply_permission_payload( + grants: &[CompiledChangeRequestApplyPermission], +) -> Vec { let mut grants = grants.iter().collect::>(); grants.sort_by(|left, right| { (&left.profile_id, &left.target_entity_id) diff --git a/crates/registry-breg/src/compiler.rs b/crates/registry-breg/src/compiler.rs index 46c9429360..d5aa93873c 100644 --- a/crates/registry-breg/src/compiler.rs +++ b/crates/registry-breg/src/compiler.rs @@ -15,9 +15,10 @@ use crate::contract::{ Classification, ConstraintSource, DerivedExecutionSource, DerivedFieldSource, EntityExtensionSource, EntitySource, EventConditionSource, EventScalarValue, EventTrigger, FieldSource, FieldTypeSource, GeoJsonSource, LookupValueOrigin, ManifestProjectionTextSource, - ModuleAssetSource, MutationMode, Operation, ReadPathGrantSource, RegistryModule, - RegistryProject, SpatialBboxGrantSource, SpatialQueryGrantSource, UniqueWhenPredicate, - ValidTimeRole, WebhookAuthenticationProfile, WebhookDeadLetterMode, MAX_STRUCTURED_VALUE_BYTES, + ModuleAssetSource, MutationMode, Operation, ReadPathPermissionSource, RegistryModule, + RegistryProject, SpatialBboxPermissionSource, SpatialQueryPermissionSource, + UniqueWhenPredicate, ValidTimeRole, WebhookAuthenticationProfile, WebhookDeadLetterMode, + MAX_STRUCTURED_VALUE_BYTES, }; use crate::derived_sql::validate_derived_sql; use crate::diagnostics::{CompileFailure, Diagnostic}; @@ -1595,46 +1596,131 @@ fn expand_project_access( "an authenticated profile requires a direct principal claim", )); } + if profile.requester_clients.iter().any(|client| { + client.is_empty() + || client.len() > 512 + || client.chars().any(char::is_control) + || client.chars().any(char::is_whitespace) + }) { + errors.push(Diagnostic::error( + "access_profile.requester_client.invalid", + "project.accessProfiles[].requesterClients", + "a requester client must be a bounded non-whitespace identifier", + )); + } + if profile.actor_kind.is_some() != !profile.requester_clients.is_empty() { + errors.push(Diagnostic::error( + "access_profile.actor_client.binding_required", + "project.accessProfiles[]", + "actorKind and requesterClients must be declared together", + )); + } + if profile.task_grant.is_some() + && (profile.anonymous + || profile.actor_kind != Some(crate::contract::ActorKindSource::Agent) + || profile.requester_clients.is_empty() + || profile.required_purposes.is_empty()) + { + errors.push(Diagnostic::error( + "access_profile.task_grant.binding_required", + "project.accessProfiles[].taskGrant", + "a task-grant profile must be authenticated, actorKind agent, and declare requesterClients and requiredPurposes", + )); + } + if let Some(task_grant) = &profile.task_grant { + if task_grant.authority.is_empty() + || task_grant.authority.len() > 512 + || task_grant.authority.chars().any(char::is_control) + || !task_grant.source_issuer.starts_with("https://") + || task_grant.source_issuer.chars().any(char::is_whitespace) + { + errors.push(Diagnostic::error( + "access_profile.task_grant.invalid", + "project.accessProfiles[].taskGrant", + "taskGrant must declare a bounded authority and an absolute sourceIssuer URI", + )); + } + if profile.permissions.iter().any(|permission| { + let governed_request_draft = !permission.entity.is_empty() + && entities + .get(&permission.entity) + .is_some_and(|entity| entity.change_request.is_some()); + permission + .operations + .iter() + .any(|operation| match operation { + Operation::Create | Operation::Patch => !governed_request_draft, + Operation::Tombstone | Operation::Batch | Operation::Invoke => true, + _ => false, + }) + }) { + errors.push(Diagnostic::error( + "access_profile.task_grant.direct_mutation_forbidden", + "project.accessProfiles[].permissions[].operations", + "a task-grant profile can author only governed request drafts; direct target mutations, batch operations, tombstones, and immediate actions are forbidden", + )); + } + } + let compiled_task_grant = profile.task_grant.as_ref().map(|task_grant| { + let permissions = profile + .permissions + .iter() + .filter(|permission| !permission.entity.is_empty()) + .filter_map(|permission| { + entities.get(&permission.entity).map(|entity| { + crate::contract::CompiledTaskGrantPermissionSource { + collection: entity.route.clone(), + operations: permission.operations.clone(), + } + }) + }) + .collect(); + crate::contract::CompiledTaskGrantSource { + authority: task_grant.authority.clone(), + source_issuer: task_grant.source_issuer.clone(), + permissions, + } + }); let mut granted_entities = BTreeSet::new(); - for grant in &profile.grants { + for grant in &profile.permissions { if grant.action.is_some() { if !grant.entity.is_empty() { errors.push(Diagnostic::error( - "access_profile.grant.target_exclusive", - "project.accessProfiles[].grants[]", - "an access grant must name either one entity or one action", + "access_profile.permission.target_exclusive", + "project.accessProfiles[].permissions[]", + "an access permission must name either one entity or one action", )); } continue; } if !grant.targets.is_empty() || !grant.results.is_empty() { errors.push(Diagnostic::error( - "access_profile.grant.action_fields_forbidden", - "project.accessProfiles[].grants[]", - "entity access grants cannot declare action target or result fields", + "access_profile.permission.action_fields_forbidden", + "project.accessProfiles[].permissions[]", + "entity access permissions cannot declare action target or result fields", )); } if grant.entity.is_empty() { errors.push(Diagnostic::error( - "access_profile.grant.target_missing", - "project.accessProfiles[].grants[]", - "an access grant must name either one entity or one action", + "access_profile.permission.target_missing", + "project.accessProfiles[].permissions[]", + "an access permission must name either one entity or one action", )); continue; } if !granted_entities.insert(grant.entity.as_str()) { errors.push(Diagnostic::error( - "access_profile.grant.duplicate", - "project.accessProfiles[].grants[].entity", - "an access profile contains duplicate entity grants", + "access_profile.permission.duplicate", + "project.accessProfiles[].permissions[].entity", + "an access profile contains duplicate entity permissions", )); continue; } let Some(entity) = entities.get_mut(&grant.entity) else { errors.push(Diagnostic::error( - "access_profile.grant.entity_unknown", - "project.accessProfiles[].grants[].entity", - "an access grant refers to an unknown entity", + "access_profile.permission.entity_unknown", + "project.accessProfiles[].permissions[].entity", + "an access permission refers to an unknown entity", )); continue; }; @@ -1654,6 +1740,9 @@ fn expand_project_access( id: profile.id.clone(), default: profile.default, anonymous: profile.anonymous, + actor_kind: profile.actor_kind, + requester_clients: profile.requester_clients.clone(), + task_grant: compiled_task_grant.clone(), principal_claim: profile.principal_claim.clone(), required_scopes: profile.required_scopes.clone(), required_purposes: profile.required_purposes.clone(), @@ -1786,7 +1875,7 @@ fn validate_entities( None if grants_batch => errors.push(Diagnostic::error( "entity.batch.required", "entities[].batch", - "an entity granted batch access must declare bounded batch configuration", + "an entity permissioned batch access must declare bounded batch configuration", )), Some(batch) if batch.maximum_items == 0 @@ -3097,8 +3186,8 @@ fn validate_profiles( )); } } - validate_lookup_grants(access, entity, &fields, errors); - validate_read_path_grants(access, entity, entities, errors); + validate_lookup_permissions(access, entity, &fields, errors); + validate_read_path_permissions(access, entity, entities, errors); if access.allow_count && !access.operations.contains(&Operation::List) && !access.operations.contains(&Operation::Snapshot) @@ -3106,7 +3195,7 @@ fn validate_profiles( errors.push(Diagnostic::error( "access_profile.count.unavailable", "entities[].accessProfiles[].allowCount", - "direct count access requires an explicit list or snapshot grant", + "direct count access requires an explicit list or snapshot permission", )); } } @@ -3188,7 +3277,7 @@ fn validate_spatial_queries( errors.push(Diagnostic::error( "access_profile.spatial_queries.empty", "entities[].accessProfiles[].spatialQueries", - "spatial query grants must declare one supported query", + "spatial query permissions must declare one supported query", )); return; } @@ -3215,7 +3304,7 @@ fn validate_spatial_queries( errors.push(Diagnostic::error( "access_profile.spatial_queries.bbox.list_required", "entities[].accessProfiles[].spatialQueries.bbox", - "bbox spatial queries require an explicit list grant", + "bbox spatial queries require an explicit list permission", )); } let Some(geojson) = &entity.geojson else { @@ -3264,7 +3353,7 @@ fn validate_bbox_span( } } -fn validate_lookup_grants( +fn validate_lookup_permissions( access: &AccessProfileSource, entity: &EntitySource, fields: &BTreeMap<&str, &FieldSource>, @@ -3277,7 +3366,7 @@ fn validate_lookup_grants( errors.push(Diagnostic::error( "access_profile.lookup.operation_required", "entities[].accessProfiles[].lookups", - "lookup grants require the lookup operation", + "lookup permissions require the lookup operation", )); } let selectors = entity @@ -3291,14 +3380,14 @@ fn validate_lookup_grants( errors.push(Diagnostic::error( "access_profile.lookup.duplicate", "entities[].accessProfiles[].lookups", - "lookup selector grants must be unique", + "lookup selector permissions must be unique", )); } let Some(selector) = selectors.get(lookup.selector.as_str()) else { errors.push(Diagnostic::error( "access_profile.lookup.selector_unknown", "entities[].accessProfiles[].lookups[].selector", - "a lookup grant refers to an unknown selector profile", + "a lookup permission refers to an unknown selector profile", )); continue; }; @@ -3344,7 +3433,7 @@ fn validate_lookup_grants( } } -fn validate_read_path_grants( +fn validate_read_path_permissions( access: &AccessProfileSource, entity: &EntitySource, entities: &BTreeMap, @@ -3361,27 +3450,27 @@ fn validate_read_path_grants( errors.push(Diagnostic::error( "access_profile.read_path.duplicate", "entities[].accessProfiles[].readPaths", - "read-path grants must be unique", + "read-path permissions must be unique", )); } let Some(path) = paths.get(grant.path.as_str()) else { errors.push(Diagnostic::error( "access_profile.read_path.unknown", "entities[].accessProfiles[].readPaths[].path", - "a read-path grant refers to an unknown path", + "a read-path permission refers to an unknown path", )); continue; }; - validate_read_path_grant_fields(access, entity, entities, path, grant, errors); + validate_read_path_permission_fields(access, entity, entities, path, grant, errors); } } -fn validate_read_path_grant_fields( +fn validate_read_path_permission_fields( access: &AccessProfileSource, source: &EntitySource, entities: &BTreeMap, path: &crate::contract::ReadPathSource, - grant: &ReadPathGrantSource, + grant: &ReadPathPermissionSource, errors: &mut Vec, ) { let Some(target) = entities.get(&path.to) else { @@ -3396,7 +3485,7 @@ fn validate_read_path_grant_fields( errors.push(Diagnostic::error( "access_profile.read_path.readable_fields_empty", "entities[].accessProfiles[].readPaths[].readableFields", - "a read-path grant must declare readable fields", + "a read-path permission must declare readable fields", )); } if !grant.filterable_fields.is_subset(&grant.readable_fields) @@ -3451,7 +3540,7 @@ fn validate_read_path_grant_fields( errors.push(Diagnostic::error( "access_profile.read_path.field_unknown", "entities[].accessProfiles[].readPaths[]", - "a read-path grant refers to an unknown target field", + "a read-path permission refers to an unknown target field", )); } if access.anonymous { @@ -3492,7 +3581,7 @@ fn validate_read_path_grant_fields( errors.push(Diagnostic::error( "access_profile.read_path.self_target", "entities[].accessProfiles[].readPaths[].path", - "a read-path grant cannot target the source entity", + "a read-path permission cannot target the source entity", )); } } @@ -4735,7 +4824,7 @@ fn review_route_profile_covers_stage( stage: &str, ) -> bool { plan.target_entities.iter().all(|target| { - plan.review_grants.iter().any(|grant| { + plan.review_permissions.iter().any(|grant| { grant.profile_id == profile_id && grant.stage == stage && grant.target_entity_id == *target @@ -4748,7 +4837,7 @@ fn apply_route_profile_covers_targets( profile_id: &str, ) -> bool { plan.target_entities.iter().all(|target| { - plan.apply_grants + plan.apply_permissions .iter() .any(|grant| grant.profile_id == profile_id && grant.target_entity_id == *target) }) @@ -5107,7 +5196,7 @@ fn read_path_query_operation( source: &CompiledEntity, target: &CompiledEntity, profile: &AccessProfileSource, - grant: &ReadPathGrantSource, + grant: &ReadPathPermissionSource, route_id: &str, errors: &mut Vec, ) -> Option { @@ -5338,9 +5427,9 @@ fn compiled_spatial_capability( entity: &CompiledEntity, profile: &AccessProfileSource, ) -> Option { - let SpatialQueryGrantSource { + let SpatialQueryPermissionSource { bbox: - Some(SpatialBboxGrantSource { + Some(SpatialBboxPermissionSource { maximum_longitude_span_degrees, maximum_latitude_span_degrees, }), diff --git a/crates/registry-breg/src/contract.rs b/crates/registry-breg/src/contract.rs index 868890351b..e904726cad 100644 --- a/crates/registry-breg/src/contract.rs +++ b/crates/registry-breg/src/contract.rs @@ -1961,6 +1961,12 @@ pub struct AccessProfileSource { pub default: bool, #[serde(default)] pub anonymous: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_kind: Option, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub requester_clients: BTreeSet, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_grant: Option, #[serde(default)] pub principal_claim: Option, #[serde(default)] @@ -1985,7 +1991,7 @@ pub struct AccessProfileSource { #[serde(default)] pub sortable_fields: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] - pub spatial_queries: Option, + pub spatial_queries: Option, /// Explicit row reach; an empty array intentionally permits all rows. pub row_boundaries: Vec, /// Current active membership required for each stored reference key. @@ -1995,18 +2001,18 @@ pub struct AccessProfileSource { #[serde(default, skip_serializing_if = "Option::is_none")] pub request_visibility: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub lookups: Vec, + pub lookups: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub read_paths: Vec, + pub read_paths: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub review_stages: Vec, + pub review_stages: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub apply_targets: Vec, + pub apply_targets: Vec, /// Native-reference targets requiring current same-profile GET authority at intake and preparation. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub submitter_targets: BTreeSet, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub request_presence: Vec, + pub request_presence: Vec, #[serde(default, skip_serializing_if = "is_false")] pub allow_count: bool, #[serde(default)] @@ -2060,6 +2066,15 @@ pub enum Operation { Invoke, } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorKindSource { + Human, + Agent, + Service, +} + #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -2218,6 +2233,12 @@ pub struct ProjectAccessProfileSource { pub default: bool, #[serde(default)] pub anonymous: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_kind: Option, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub requester_clients: BTreeSet, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_grant: Option, #[serde(default)] pub principal_claim: Option, #[serde(default)] @@ -2227,12 +2248,37 @@ pub struct ProjectAccessProfileSource { /// The verified token's purpose must match one listed value. Empty means no purpose restriction. pub required_purposes: BTreeSet, #[serde(default)] - pub grants: Vec, + pub permissions: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TaskGrantSource { + pub authority: String, + pub source_issuer: String, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledTaskGrantSource { + pub authority: String, + pub source_issuer: String, + pub permissions: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CompiledTaskGrantPermissionSource { + pub collection: String, + pub operations: BTreeSet, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct AccessGrantSource { +pub struct AccessPermissionSource { #[serde(default, skip_serializing_if = "String::is_empty")] pub entity: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2253,7 +2299,7 @@ pub struct AccessGrantSource { #[serde(default)] pub sortable_fields: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] - pub spatial_queries: Option, + pub spatial_queries: Option, #[serde(default)] pub row_boundaries: Vec, /// Current active membership required for each stored reference key. @@ -2263,20 +2309,20 @@ pub struct AccessGrantSource { #[serde(default, skip_serializing_if = "Option::is_none")] pub request_visibility: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub lookups: Vec, + pub lookups: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub read_paths: Vec, + pub read_paths: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub review_stages: Vec, + pub review_stages: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub apply_targets: Vec, + pub apply_targets: Vec, /// Native-reference targets requiring current same-profile GET authority at intake and preparation. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub submitter_targets: BTreeSet, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub request_presence: Vec, + pub request_presence: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub targets: Vec, + pub targets: Vec, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub results: BTreeSet, #[serde(default, skip_serializing_if = "is_false")] @@ -2291,7 +2337,7 @@ pub struct AccessGrantSource { #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct RawAccessGrantSource { +struct RawAccessPermissionSource { #[serde(default, skip_serializing_if = "String::is_empty")] entity: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2312,7 +2358,7 @@ struct RawAccessGrantSource { #[serde(default)] sortable_fields: BTreeSet, #[serde(default, skip_serializing_if = "Option::is_none")] - spatial_queries: Option, + spatial_queries: Option, #[serde(default)] row_boundaries: Option>, #[serde(default)] @@ -2321,19 +2367,19 @@ struct RawAccessGrantSource { #[serde(default, skip_serializing_if = "Option::is_none")] request_visibility: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] - lookups: Vec, + lookups: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - read_paths: Vec, + read_paths: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - review_stages: Vec, + review_stages: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - apply_targets: Vec, + apply_targets: Vec, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] submitter_targets: BTreeSet, #[serde(default, skip_serializing_if = "Vec::is_empty")] - request_presence: Vec, + request_presence: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] - targets: Vec, + targets: Vec, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] results: BTreeSet, #[serde(default, skip_serializing_if = "is_false")] @@ -2347,16 +2393,16 @@ struct RawAccessGrantSource { } // Entity grants must state their row reach. Action invocation itself has no -// rows; its target grants carry the independently required declarations. -impl<'de> Deserialize<'de> for AccessGrantSource { +// rows; its target permissions carry the independently required declarations. +impl<'de> Deserialize<'de> for AccessPermissionSource { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { - let raw = RawAccessGrantSource::deserialize(deserializer)?; + let raw = RawAccessPermissionSource::deserialize(deserializer)?; if !raw.entity.is_empty() && raw.row_boundaries.is_none() { return Err(D::Error::custom( - "entity grants require rowBoundaries; use an explicit empty array for intentional all-row access", + "entity permissions require rowBoundaries; use an explicit empty array for intentional all-row access", )); } Ok(Self { @@ -2389,17 +2435,17 @@ impl<'de> Deserialize<'de> for AccessGrantSource { } #[cfg(feature = "schema")] -impl schemars::JsonSchema for AccessGrantSource { +impl schemars::JsonSchema for AccessPermissionSource { fn schema_name() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed("AccessGrantSource") + std::borrow::Cow::Borrowed("AccessPermissionSource") } fn schema_id() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed(concat!(module_path!(), "::AccessGrantSource")) + std::borrow::Cow::Borrowed(concat!(module_path!(), "::AccessPermissionSource")) } fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - AccessGrantSourceSchema::json_schema(generator) + AccessPermissionSourceSchema::json_schema(generator) } } @@ -2407,16 +2453,16 @@ impl schemars::JsonSchema for AccessGrantSource { #[allow(dead_code)] #[derive(schemars::JsonSchema)] #[serde(untagged)] -enum AccessGrantSourceSchema { - Entity(Box), - Action(ActionAccessGrantSourceSchema), +enum AccessPermissionSourceSchema { + Entity(Box), + Action(ActionAccessPermissionSourceSchema), } #[cfg(feature = "schema")] #[allow(dead_code)] #[derive(schemars::JsonSchema)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct EntityAccessGrantSourceSchema { +struct EntityAccessPermissionSourceSchema { entity: String, operations: BTreeSet, #[serde(default)] @@ -2434,7 +2480,7 @@ struct EntityAccessGrantSourceSchema { #[serde(default)] sortable_fields: BTreeSet, #[serde(default)] - spatial_queries: Option, + spatial_queries: Option, row_boundaries: Vec, /// Current active membership required for each stored reference key. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -2442,17 +2488,17 @@ struct EntityAccessGrantSourceSchema { #[serde(default)] request_visibility: Option, #[serde(default)] - lookups: Vec, + lookups: Vec, #[serde(default)] - read_paths: Vec, + read_paths: Vec, #[serde(default)] - review_stages: Vec, + review_stages: Vec, #[serde(default)] - apply_targets: Vec, + apply_targets: Vec, #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] submitter_targets: BTreeSet, #[serde(default)] - request_presence: Vec, + request_presence: Vec, #[serde(default)] allow_count: bool, #[serde(default)] @@ -2465,11 +2511,11 @@ struct EntityAccessGrantSourceSchema { #[allow(dead_code)] #[derive(schemars::JsonSchema)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -struct ActionAccessGrantSourceSchema { +struct ActionAccessPermissionSourceSchema { action: String, operations: BTreeSet, #[serde(default)] - targets: Vec, + targets: Vec, #[serde(default)] results: BTreeSet, } @@ -2477,15 +2523,15 @@ struct ActionAccessGrantSourceSchema { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct SpatialQueryGrantSource { +pub struct SpatialQueryPermissionSource { #[serde(default, skip_serializing_if = "Option::is_none")] - pub bbox: Option, + pub bbox: Option, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct SpatialBboxGrantSource { +pub struct SpatialBboxPermissionSource { pub maximum_longitude_span_degrees: serde_json::Number, pub maximum_latitude_span_degrees: serde_json::Number, } @@ -2493,7 +2539,7 @@ pub struct SpatialBboxGrantSource { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct LookupGrantSource { +pub struct LookupPermissionSource { pub selector: String, pub value_origin: LookupValueOrigin, #[serde(default)] @@ -2511,7 +2557,7 @@ pub enum LookupValueOrigin { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ReadPathGrantSource { +pub struct ReadPathPermissionSource { pub path: String, #[serde(default)] pub readable_fields: BTreeSet, @@ -2526,16 +2572,16 @@ pub struct ReadPathGrantSource { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ReviewStageGrantSource { +pub struct ReviewStagePermissionSource { pub stage: String, #[serde(default)] - pub targets: Vec, + pub targets: Vec, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ReviewStageTargetGrantSource { +pub struct ReviewStageTargetPermissionSource { pub entity: String, #[serde(default)] pub readable_fields: BTreeSet, @@ -2546,7 +2592,7 @@ pub struct ReviewStageTargetGrantSource { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ApplyTargetGrantSource { +pub struct ApplyTargetPermissionSource { pub entity: String, /// Explicit row reach; an empty array intentionally permits all rows. pub row_boundaries: Vec, @@ -2555,7 +2601,7 @@ pub struct ApplyTargetGrantSource { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct RequestPresenceGrantSource { +pub struct RequestPresencePermissionSource { pub request_type: String, /// Explicit row reach; an empty array intentionally permits all rows. pub row_boundaries: Vec, @@ -2572,7 +2618,7 @@ pub enum RequestVisibilitySource { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct ActionTargetGrantSource { +pub struct ActionTargetPermissionSource { pub entity: String, /// Explicit row reach; an empty array intentionally permits all rows. pub row_boundaries: Vec, @@ -2599,7 +2645,7 @@ pub enum EventTrigger { pub fn parse_project_json(bytes: &[u8]) -> Result { match parse_json(bytes, "project") { Ok(project) => Ok(project), - Err(failure) => Err(removed_singular_projection_diagnostics( + Err(failure) => Err(removed_project_field_diagnostics( parse_json_strict(bytes).ok().as_ref(), ) .unwrap_or(failure)), @@ -2615,27 +2661,43 @@ pub fn parse_project_yaml(bytes: &[u8]) -> Result Ok(project), Err(failure) => { let value = serde_norway::from_slice::(bytes).ok(); - Err(removed_singular_projection_diagnostics(value.as_ref()).unwrap_or(failure)) + Err(removed_project_field_diagnostics(value.as_ref()).unwrap_or(failure)) } } } -fn removed_singular_projection_diagnostics(value: Option<&Value>) -> Option { - let projection = value?.as_object()?.get("manifestProjection")?.as_object()?; +fn removed_project_field_diagnostics(value: Option<&Value>) -> Option { + let project = value?.as_object()?; let mut diagnostics = Vec::new(); - if projection.contains_key("dataset") { - diagnostics.push(Diagnostic::error( - "manifest_projection.dataset.removed", - "project.manifestProjection.dataset", - "manifestProjection.dataset was removed; use manifestProjection.datasets[] or run `bregctl project migrate `", - )); + if let Some(projection) = project.get("manifestProjection").and_then(Value::as_object) { + if projection.contains_key("dataset") { + diagnostics.push(Diagnostic::error( + "manifest_projection.dataset.removed", + "project.manifestProjection.dataset", + "manifestProjection.dataset was removed; use manifestProjection.datasets[] or run `bregctl project migrate `", + )); + } + if projection.contains_key("dataService") { + diagnostics.push(Diagnostic::error( + "manifest_projection.data_service.removed", + "project.manifestProjection.dataService", + "manifestProjection.dataService was removed; use manifestProjection.dataServices[] or run `bregctl project migrate `", + )); + } } - if projection.contains_key("dataService") { - diagnostics.push(Diagnostic::error( - "manifest_projection.data_service.removed", - "project.manifestProjection.dataService", - "manifestProjection.dataService was removed; use manifestProjection.dataServices[] or run `bregctl project migrate `", - )); + if let Some(profiles) = project.get("accessProfiles").and_then(Value::as_array) { + for (index, profile) in profiles.iter().enumerate() { + if profile + .as_object() + .is_some_and(|profile| profile.contains_key("grants")) + { + diagnostics.push(Diagnostic::error( + "access_profile.grants.removed", + format!("project.accessProfiles[{index}].grants"), + "accessProfiles[].grants was replaced by accessProfiles[].permissions; rename the key to permissions", + )); + } + } } (!diagnostics.is_empty()).then(|| CompileFailure::from_errors(diagnostics)) } diff --git a/crates/registry-breg/src/evidence_source.rs b/crates/registry-breg/src/evidence_source.rs index 6d55bc689f..c5ce740307 100644 --- a/crates/registry-breg/src/evidence_source.rs +++ b/crates/registry-breg/src/evidence_source.rs @@ -17,7 +17,7 @@ use crate::{ GeneratedArtifact, }; -/// Technical choices only. Questions, grants, targets and credentials stay with +/// Technical choices only. Questions, permissions, targets and credentials stay with /// the Evidence project and its operator. pub struct EvidenceSourceOptions { pub access_profile: String, @@ -146,7 +146,7 @@ pub fn supports_scalar_fact(kind: &FieldTypeSource) -> bool { } /// Whether a compiled field type fits the canonical Evidence selector schema. -/// Exact source export still validates identity grants, names and total bounds. +/// Exact source export still validates identity permissions, names and total bounds. pub fn supports_selector_field(kind: &FieldTypeSource) -> bool { selector_schema(kind).is_ok() } diff --git a/crates/registry-breg/src/evidence_source/tests.rs b/crates/registry-breg/src/evidence_source/tests.rs index dcb5da6cdd..3d2abe7998 100644 --- a/crates/registry-breg/src/evidence_source/tests.rs +++ b/crates/registry-breg/src/evidence_source/tests.rs @@ -16,7 +16,7 @@ fn project() -> Value { {"id":"tenant","type":"string","maxLength":16,"classification":"internal"}], "selectorProfiles":[{"id":"by-code","fields":["code"]},{"id":"by-registration-number","fields":["registration-number"]}], "derived":[{"id":"status-read","sql":"status.sql","key":"id","execution":"live","fields":[{"id":"active","type":"boolean","classification":"internal"}]}]}], - "accessProfiles":[{"id":"evidence-source","principalClaim":"principal","requiredScopes":["registry.read"],"grants":[{ + "accessProfiles":[{"id":"evidence-source","principalClaim":"principal","requiredScopes":["registry.read"],"permissions":[{ "entity":"record","operations":["lookup"],"readableFields":["code","registration-number","status","active"], "lookups":[{"selector":"by-code","valueOrigin":"request"},{"selector":"by-registration-number","valueOrigin":"request"}], "rowBoundaries":[{"field":"tenant","claim":"tenant","operator":"equals"}]}]}]}) @@ -169,7 +169,7 @@ fn consumed_behavior_ignores_unselected_fields_but_reaches_sql_and_authority() { .behavior_revision ); let mut authority = original.clone(); - authority["accessProfiles"][0]["grants"][0]["rowBoundaries"][0]["claim"] = + authority["accessProfiles"][0]["permissions"][0]["rowBoundaries"][0]["claim"] = json!("other_tenant"); assert_ne!( before.behavior_revision, @@ -195,7 +195,7 @@ fn membership_project() -> Value { {"id":"active","type":"boolean","classification":"internal"}, {"id":"other-active","type":"boolean","classification":"internal"}]}), ]); - source["accessProfiles"][0]["grants"][0]["membershipBoundaries"] = json!([{ + source["accessProfiles"][0]["permissions"][0]["membershipBoundaries"] = json!([{ "field":"organization","membershipEntity":"membership","membershipKeyField":"organization", "principalField":"principal","activeField":"active" }]); @@ -213,7 +213,8 @@ fn membership_behavior_reaches_helper_semantics_source_fields_and_select_authori ("membershipKeyField", "other-organization"), ] { let mut changed = original.clone(); - changed["accessProfiles"][0]["grants"][0]["membershipBoundaries"][0][name] = json!(value); + changed["accessProfiles"][0]["permissions"][0]["membershipBoundaries"][0][name] = + json!(value); let after_registry = compiled(&changed, SQL); assert_eq!( select_policies(®istry, ®istry.entities()["record"], "evidence-source"), @@ -242,7 +243,7 @@ fn membership_behavior_reaches_helper_semantics_source_fields_and_select_authori "the principal field contract is consumed even when helper SQL stays the same" ); let mut changed_policy = original.clone(); - changed_policy["accessProfiles"][0]["grants"].as_array_mut().unwrap().push(json!({ + changed_policy["accessProfiles"][0]["permissions"].as_array_mut().unwrap().push(json!({ "entity":"membership","operations":["get"],"readableFields":["active"],"rowBoundaries":[] })); assert_ne!( @@ -275,7 +276,7 @@ fn membership_behavior_reaches_helper_semantics_source_fields_and_select_authori #[test] fn reached_derived_source_consumes_its_membership_helper() { let mut original = membership_project(); - let boundary = original["accessProfiles"][0]["grants"][0] + let boundary = original["accessProfiles"][0]["permissions"][0] .as_object_mut() .unwrap() .remove("membershipBoundaries") @@ -286,7 +287,7 @@ fn reached_derived_source_consumes_its_membership_helper() { {"id":"organization","type":"reference","target":"organization","classification":"internal"}, {"id":"enabled","type":"boolean","classification":"internal"}] })); - original["accessProfiles"][0]["grants"].as_array_mut().unwrap().push(json!({ + original["accessProfiles"][0]["permissions"].as_array_mut().unwrap().push(json!({ "entity":"flag","operations":["get"],"readableFields":["code","enabled"],"rowBoundaries":[], "membershipBoundaries":boundary })); @@ -294,7 +295,7 @@ fn reached_derived_source_consumes_its_membership_helper() { let mut selection = options(); selection.fields = vec!["active".into()]; let before = export_evidence_source(&compiled(&original, sql), &selection).unwrap(); - original["accessProfiles"][0]["grants"][1]["membershipBoundaries"][0]["principalField"] = + original["accessProfiles"][0]["permissions"][1]["membershipBoundaries"][0]["principalField"] = json!("other-principal"); assert_ne!( before.behavior_revision, @@ -308,10 +309,10 @@ fn reached_derived_source_consumes_its_membership_helper() { #[test] fn refuses_ungiven_authority_and_incompatible_selector_semantics() { let mut hidden = project(); - hidden["accessProfiles"][0]["grants"][0]["readableFields"] = json!(["status", "active"]); + hidden["accessProfiles"][0]["permissions"][0]["readableFields"] = json!(["status", "active"]); assert!(export_evidence_source(&compiled(&hidden, SQL), &options()).is_err()); let mut claims = project(); - claims["accessProfiles"][0]["grants"][0]["lookups"][0] = + claims["accessProfiles"][0]["permissions"][0]["lookups"][0] = json!({"selector":"by-code","valueOrigin":"verified_claim","claimMapping":{"code":"code"}}); assert!(export_evidence_source(&compiled(&claims, SQL), &options()).is_err()); assert!(selector_schema(&FieldTypeSource::Int64).is_err()); @@ -360,12 +361,12 @@ fn reached_source_select_authority_is_part_of_consumed_behavior() { original["entities"].as_array_mut().unwrap().push(json!({"id":"flag","primaryDataset":"test-dataset","route":"flags","mutationMode":"mutable","fields":[ {"id":"code","type":"string","maxLength":32,"classification":"internal"}, {"id":"enabled","type":"boolean","classification":"internal"}]})); - original["accessProfiles"][0]["grants"].as_array_mut().unwrap().push(json!({"entity":"flag","operations":["get"],"readableFields":["code","enabled"],"rowBoundaries":[]})); + original["accessProfiles"][0]["permissions"].as_array_mut().unwrap().push(json!({"entity":"flag","operations":["get"],"readableFields":["code","enabled"],"rowBoundaries":[]})); let sql="SELECT r.id AS id, f.enabled AS active FROM registry_source.record r JOIN registry_source.flag f ON f.code = r.code"; let mut selection = options(); selection.fields = vec!["active".into()]; let before = export_evidence_source(&compiled(&original, sql), &selection).unwrap(); - original["accessProfiles"][0]["grants"][1] = json!({"entity":"flag","operations":["create"],"writableFields":["code","enabled"],"rowBoundaries":[]}); + original["accessProfiles"][0]["permissions"][1] = json!({"entity":"flag","operations":["create"],"writableFields":["code","enabled"],"rowBoundaries":[]}); let after = export_evidence_source(&compiled(&original, sql), &selection).unwrap(); assert_ne!( before.behavior_revision, after.behavior_revision, @@ -417,7 +418,7 @@ fn refuses_alternative_union_that_exceeds_runtime_projection_bound() { .as_array_mut() .unwrap() .push(json!({"id":id,"type":"string","maxLength":1,"classification":"internal"})); - original["accessProfiles"][0]["grants"][0]["readableFields"] + original["accessProfiles"][0]["permissions"][0]["readableFields"] .as_array_mut() .unwrap() .push(json!(id)); @@ -427,7 +428,7 @@ fn refuses_alternative_union_that_exceeds_runtime_projection_bound() { .as_array_mut() .unwrap() .push(json!({"id":selector,"fields":fields})); - original["accessProfiles"][0]["grants"][0]["lookups"] + original["accessProfiles"][0]["permissions"][0]["lookups"] .as_array_mut() .unwrap() .push(json!({"selector":selector,"valueOrigin":"request"})); @@ -476,13 +477,13 @@ fn refuses_change_request_lifecycle_entities() { .as_array_mut() .unwrap() .push(json!({"id":"record-request-steward","principalClaim":"principal","requiredScopes":["registry.write"], - "grants":[{"entity":"record-request","rowBoundaries":[], + "permissions":[{"entity":"record-request","rowBoundaries":[], "operations":["create","get","submit_request","approve_request","apply_request"], "readableFields":["code","subject","new-status"], "writableFields":["code","subject","new-status"], "reviewStages":[{"stage":"review","targets":[{"entity":"record","readableFields":["status"],"rowBoundaries":[]}]}], "applyTargets":[{"entity":"record","rowBoundaries":[]}]}]})); - original["accessProfiles"][0]["grants"] + original["accessProfiles"][0]["permissions"] .as_array_mut() .unwrap() .push( @@ -505,8 +506,8 @@ fn refuses_change_request_lifecycle_entities() { #[test] fn refuses_a_profile_that_does_not_grant_lookup() { let mut original = project(); - original["accessProfiles"][0]["grants"][0]["operations"] = json!(["get"]); - original["accessProfiles"][0]["grants"][0]["lookups"] = json!([]); + original["accessProfiles"][0]["permissions"][0]["operations"] = json!(["get"]); + original["accessProfiles"][0]["permissions"][0]["lookups"] = json!([]); let diagnostic = refused(&compiled(&original, SQL), &options()); assert_eq!(diagnostic.code, "evidence_source.refused"); assert_eq!( @@ -547,11 +548,11 @@ fn long_eligible_selector_names_export_as_stable_distinct_bounded_profiles() { .as_object_mut() .unwrap() .remove("derived"); - original["accessProfiles"][0]["grants"][0]["entity"] = json!(entity); - original["accessProfiles"][0]["grants"][0]["readableFields"] = json!(["code", "status"]); + original["accessProfiles"][0]["permissions"][0]["entity"] = json!(entity); + original["accessProfiles"][0]["permissions"][0]["readableFields"] = json!(["code", "status"]); original["entities"][0]["selectorProfiles"] = json!([ {"id":first,"fields":["code"]},{"id":second,"fields":["code"]}]); - original["accessProfiles"][0]["grants"][0]["lookups"] = json!([ + original["accessProfiles"][0]["permissions"][0]["lookups"] = json!([ {"selector":first,"valueOrigin":"request"},{"selector":second,"valueOrigin":"request"}]); let source = parse_project_json(&serde_json::to_vec(&original).unwrap()).unwrap(); let registry = @@ -597,7 +598,7 @@ fn refuses_a_composite_selector_beyond_the_aggregate_selector_bound() { .as_array_mut() .unwrap() .push(json!({"id":id,"type":"string","minLength":1,"maxLength":1000,"classification":"internal"})); - original["accessProfiles"][0]["grants"][0]["readableFields"] + original["accessProfiles"][0]["permissions"][0]["readableFields"] .as_array_mut() .unwrap() .push(json!(id)); @@ -607,7 +608,7 @@ fn refuses_a_composite_selector_beyond_the_aggregate_selector_bound() { .as_array_mut() .unwrap() .push(json!({"id":"by-parts","fields":fields})); - original["accessProfiles"][0]["grants"][0]["lookups"] + original["accessProfiles"][0]["permissions"][0]["lookups"] .as_array_mut() .unwrap() .push(json!({"selector":"by-parts","valueOrigin":"request"})); diff --git a/crates/registry-breg/src/fixtures.rs b/crates/registry-breg/src/fixtures.rs index 171a9d43a3..808b522578 100644 --- a/crates/registry-breg/src/fixtures.rs +++ b/crates/registry-breg/src/fixtures.rs @@ -37,7 +37,9 @@ use crate::data::{validate_field_value, FieldValue}; use crate::derived_sql::MAX_DERIVED_SQL_BYTES; use crate::event_destination::EventDestinationActivationError; use crate::model::CompiledRoute; -use crate::model::{ActionRouteKind, CompiledAction, CompiledActionGrant, CompiledActionRoute}; +use crate::model::{ + ActionRouteKind, CompiledAction, CompiledActionPermission, CompiledActionRoute, +}; use crate::model::{CompiledQueryKind, CompiledQueryOperation, CompiledRegistry, HttpMethod}; #[cfg(any(test, feature = "postgres-test"))] use crate::package::{canonical_signed_bytes as package_canonical_signed_bytes, VerifiedPackage}; @@ -509,6 +511,12 @@ struct ClaimsSource { purpose: Option, #[serde(default)] direct_claims: BTreeMap, + #[serde(default)] + actor_kind: Option, + #[serde(default)] + requester_client: Option, + #[serde(default)] + actor_subject: Option, } #[derive(Clone, Deserialize)] @@ -814,7 +822,7 @@ pub fn validate_fixture_journeys( .cloned() .ok_or(FixtureError::LogicalReferenceRefused)?; let grant = action - .grants + .permissions .iter() .find(|grant| grant.profile_id == step.access_profile) .ok_or(FixtureError::LogicalReferenceRefused)?; @@ -1258,6 +1266,9 @@ fn validate_claims( || !claims.scopes.is_empty() || claims.purpose.is_some() || !claims.direct_claims.is_empty() + || claims.actor_kind.is_some() + || claims.requester_client.is_some() + || claims.actor_subject.is_some() { return Err(FixtureError::AuthorityWideningRefused); } @@ -1272,6 +1283,16 @@ fn validate_claims( { return Err(FixtureError::AuthorityWideningRefused); } + if claims.actor_kind != profile.actor_kind + || claims + .requester_client + .as_ref() + .is_some_and(|client| !profile.requester_clients.contains(client)) + || profile.actor_kind == Some(crate::contract::ActorKindSource::Agent) + && (claims.requester_client.is_none() || claims.actor_subject.is_none()) + { + return Err(FixtureError::AuthorityWideningRefused); + } let boundary_claims = profile .row_boundaries .iter() @@ -1308,11 +1329,14 @@ fn immediate_action_route_kind(action: &ActionSource) -> Result AccessProfileSource { +fn action_profile_from_grant(grant: &CompiledActionPermission) -> AccessProfileSource { AccessProfileSource { id: grant.profile_id.clone(), default: grant.default, anonymous: grant.anonymous, + actor_kind: grant.actor_kind, + requester_clients: grant.requester_clients.clone(), + task_grant: None, principal_claim: grant.principal_claim.clone(), required_scopes: grant.required_scopes.clone(), required_purposes: grant.required_purposes.clone(), @@ -1495,7 +1519,7 @@ fn condition_input_api_names(action: &CompiledAction) -> BTreeSet { fn validate_capture_results( request: &ActionSource, action: &CompiledAction, - grant: &CompiledActionGrant, + grant: &CompiledActionPermission, capture_results: &BTreeMap, ) -> Result, FixtureError> { let mut captures = BTreeMap::new(); @@ -1543,7 +1567,7 @@ fn validate_request_capture_results( .iter() .find(|effect| effect.id == *effect_id && effect.operation == Operation::Create) .ok_or(FixtureError::LogicalReferenceRefused)?; - if !change_request.apply_grants.iter().any(|grant| { + if !change_request.apply_permissions.iter().any(|grant| { grant.profile_id == profile_id && grant.target_entity_id == effect.target.entity_id }) { return Err(FixtureError::LogicalReferenceRefused); @@ -4035,6 +4059,20 @@ fn verified_claims(step: &ValidatedStep) -> Result registry_platform_oidc::ActorKind::Human, + crate::contract::ActorKindSource::Agent => registry_platform_oidc::ActorKind::Agent, + crate::contract::ActorKindSource::Service => registry_platform_oidc::ActorKind::Service, + }); + claims_context.with_contextual_authority( + actor_kind, + step.claims.requester_client.clone(), + step.claims.actor_subject.clone(), + None, + BTreeMap::new(), + ) + }) .map_err(|_| FixtureError::RequestConstructionRefused) } diff --git a/crates/registry-breg/src/generated_ddl.rs b/crates/registry-breg/src/generated_ddl.rs index 72dbe3afd5..fb596b0d46 100644 --- a/crates/registry-breg/src/generated_ddl.rs +++ b/crates/registry-breg/src/generated_ddl.rs @@ -805,7 +805,7 @@ fn request_row_boundary_fields(entity: &CompiledEntity) -> BTreeSet { }) .collect::>(); if let Some(request) = &entity.change_request { - for grant in &request.presence_grants { + for grant in &request.presence_permissions { fields.extend( grant .request_row_boundaries @@ -893,7 +893,7 @@ fn runtime_privileges( if entity .change_request .as_ref() - .is_some_and(|request| !request.presence_grants.is_empty()) + .is_some_and(|request| !request.presence_permissions.is_empty()) { privileges.insert(TablePrivilege::Select); } @@ -1242,7 +1242,7 @@ fn spatial_candidate_predicates(entity: &CompiledEntity) -> Vec { fn spatial_bbox_predicate( entity: &CompiledEntity, geometry_field: &str, - bbox: &crate::contract::SpatialBboxGrantSource, + bbox: &crate::contract::SpatialBboxPermissionSource, ) -> String { let geometry = quote_identifier(&spatial_geometry_column_name(&entity.id, geometry_field)); let source = quote_identifier(&entity.fields[geometry_field].physical_name); @@ -1698,7 +1698,7 @@ fn change_request_presence_policies_for_table( return Vec::new(); }; let mut policies = Vec::new(); - for grant in &request.presence_grants { + for grant in &request.presence_permissions { let Some(target_entity) = entities.get(&grant.target_entity_id) else { continue; }; @@ -1729,7 +1729,7 @@ fn change_request_presence_expression( request_entity: &CompiledEntity, target_profile: &crate::contract::AccessProfileSource, request: &crate::model::CompiledChangeRequest, - grant: &crate::model::CompiledChangeRequestPresenceGrant, + grant: &crate::model::CompiledChangeRequestPresencePermission, ) -> String { let context = change_request_presence_context_expression(); [ @@ -1896,7 +1896,7 @@ fn change_request_target_policies_for_table( } } for grant in request - .review_grants + .review_permissions .iter() .filter(|grant| grant.target_entity_id == target_entity.id) { @@ -1929,7 +1929,7 @@ fn change_request_target_policies_for_table( }); } for grant in request - .apply_grants + .apply_permissions .iter() .filter(|grant| grant.target_entity_id == target_entity.id) { @@ -2082,7 +2082,7 @@ fn change_request_planner_write_policies( } } for grant in request - .review_grants + .review_permissions .iter() .filter(|grant| grant.target_entity_id == target_entity.id) { @@ -2119,7 +2119,7 @@ fn change_request_planner_write_policies( }); } for grant in request - .apply_grants + .apply_permissions .iter() .filter(|grant| grant.target_entity_id == target_entity.id) { @@ -2263,7 +2263,7 @@ fn change_request_review_expression( request_entity: &CompiledEntity, request: &crate::model::CompiledChangeRequest, effect: &CompiledChangeRequestEffect, - grant: &crate::model::CompiledChangeRequestReviewGrant, + grant: &crate::model::CompiledChangeRequestReviewPermission, ) -> String { [ change_request_common_expression( @@ -2286,7 +2286,7 @@ fn change_request_planner_review_expression( request_entity: &CompiledEntity, request: &crate::model::CompiledChangeRequest, write: &crate::model::CompiledChangeRequestPlannerWrite, - grant: &crate::model::CompiledChangeRequestReviewGrant, + grant: &crate::model::CompiledChangeRequestReviewPermission, review_fields: &BTreeSet, ) -> String { [ @@ -2314,7 +2314,7 @@ fn change_request_application_expression( request_entity: &CompiledEntity, request: &crate::model::CompiledChangeRequest, effect: &CompiledChangeRequestEffect, - grant: &crate::model::CompiledChangeRequestApplyGrant, + grant: &crate::model::CompiledChangeRequestApplyPermission, ) -> String { [ change_request_common_expression( @@ -2337,7 +2337,7 @@ fn change_request_planner_application_expression( request_entity: &CompiledEntity, request: &crate::model::CompiledChangeRequest, write: &crate::model::CompiledChangeRequestPlannerWrite, - grant: &crate::model::CompiledChangeRequestApplyGrant, + grant: &crate::model::CompiledChangeRequestApplyPermission, ) -> String { [ change_request_planner_common_expression( @@ -2604,7 +2604,7 @@ fn immediate_action_target_policies_for_table( .iter() .filter(|effect| effect.target.entity_id == target_entity.id) { - for grant in action.grants.iter().filter(|grant| { + for grant in action.permissions.iter().filter(|grant| { grant.operations.contains(&Operation::Invoke) && grant .targets @@ -2711,7 +2711,7 @@ fn immediate_action_target_policies_for_table( let CompiledActionTargetUseSource::Input { input } = &target_use.source else { continue; }; - for grant in action.grants.iter().filter(|grant| { + for grant in action.permissions.iter().filter(|grant| { grant.operations.contains(&Operation::Invoke) && grant .targets @@ -2769,7 +2769,7 @@ fn immediate_action_application_expression( immediate_action_context_expression(), target_entity, action - .grants + .permissions .iter() .find(|grant| grant.profile_id == profile_id) .and_then(|grant| { @@ -2804,7 +2804,7 @@ fn immediate_action_link_expression( immediate_action_link_context_expression(), target_entity, action - .grants + .permissions .iter() .find(|grant| grant.profile_id == profile_id) .and_then(|grant| { @@ -4041,7 +4041,7 @@ mod tests { "geojson":{"geometryField":"location"} }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["get","list"],"readableFields":["code","location"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.25,"maximumLatitudeSpanDegrees":1.5}}, "rowBoundaries": [] diff --git a/crates/registry-breg/src/idempotency.rs b/crates/registry-breg/src/idempotency.rs index 208dc4d9e3..76f1b82ae4 100644 --- a/crates/registry-breg/src/idempotency.rs +++ b/crates/registry-breg/src/idempotency.rs @@ -219,7 +219,7 @@ pub(crate) fn resolve_binding( .map_err(|_| IdempotencyError::InvalidInput) }) .transpose()?; - let canonical = canonicalize_json(&json!({ + let mut authority_binding = json!({ "context": canonical_context, "method": method_name(binding.method), "route": binding.route, @@ -227,8 +227,13 @@ pub(crate) fn resolve_binding( "packageRevision": binding.package_revision, "responseFields": binding.response_fields, "canonicalRequestDigest": hex(&binding.canonical_request_digest), - })) - .map_err(|_| IdempotencyError::InvalidInput)?; + }); + if let Some(grant) = binding.context.task_grant() { + authority_binding["taskGrant"] = + serde_json::to_value(grant).map_err(|_| IdempotencyError::InvalidInput)?; + } + let canonical = + canonicalize_json(&authority_binding).map_err(|_| IdempotencyError::InvalidInput)?; let canonical = std::str::from_utf8(&canonical).map_err(|_| IdempotencyError::InvalidInput)?; let binding_reference = key_hasher .audit_reference_hash( diff --git a/crates/registry-breg/src/immediate_actions.rs b/crates/registry-breg/src/immediate_actions.rs index 5964ae9460..e7329337df 100644 --- a/crates/registry-breg/src/immediate_actions.rs +++ b/crates/registry-breg/src/immediate_actions.rs @@ -19,9 +19,9 @@ use crate::diagnostics::Diagnostic; use crate::logical_names::{default_api_name, reserved_logical_name, valid_api_name}; use crate::model::{ ActionRouteKind, CompiledAction, CompiledActionAccessEntry, CompiledActionEffect, - CompiledActionGrant, CompiledActionInput, CompiledActionInventory, CompiledActionMutation, + CompiledActionInput, CompiledActionInventory, CompiledActionMutation, CompiledActionPermission, CompiledActionRequirement, CompiledActionRoute, CompiledActionTarget, - CompiledActionTargetBinding, CompiledActionTargetGrant, CompiledActionTargetUse, + CompiledActionTargetBinding, CompiledActionTargetPermission, CompiledActionTargetUse, CompiledActionTargetUseSource, CompiledActionValue, CompiledEntity, HttpMethod, }; @@ -44,7 +44,7 @@ pub(crate) fn compile_immediate_actions( assets: &[crate::contract::ModuleAssetSource], ) -> Result> { let mut errors = Vec::new(); - validate_action_grant_sources(actions, profiles, &mut errors); + validate_action_permission_sources(actions, profiles, &mut errors); let mut compiled_actions = Vec::new(); let mut routes = Vec::new(); let mut access = Vec::new(); @@ -138,7 +138,7 @@ fn compile_action( &target_uses, errors, ); - let grants = compile_grants( + let permissions = compile_permissions( action, entities, profiles, @@ -146,11 +146,11 @@ fn compile_action( &result_effects, errors, ); - if grants.is_empty() { + if permissions.is_empty() { errors.push(Diagnostic::error( - "action.grant.missing", - "project.accessProfiles[].grants", - "an immediate action requires at least one explicit invoke grant", + "action.permission.missing", + "project.accessProfiles[].permissions", + "an immediate action requires at least one explicit invoke permission", )); } let condition_route = target_uses @@ -169,14 +169,14 @@ fn compile_action( &inputs, (&effects, handler.as_ref()), &requires, - &grants, + &permissions, ), handler, inputs, effects, requires, target_uses, - grants, + permissions, result_effects, maximum_targets: MAX_CHANGE_REQUEST_TARGETS, maximum_field_mutations: MAX_CHANGE_REQUEST_FIELD_MUTATIONS, @@ -1392,72 +1392,74 @@ fn maximum_snapshot_bytes( Some(total) } -fn compile_grants( +fn compile_permissions( action: &ActionSource, entities: &BTreeMap, profiles: &[ProjectAccessProfileSource], target_uses: &[CompiledActionTargetUse], result_effects: &BTreeSet, errors: &mut Vec, -) -> Vec { +) -> Vec { let mut grants = Vec::new(); let mut profile_action = BTreeSet::new(); for profile in profiles { for grant in profile - .grants + .permissions .iter() .filter(|grant| grant.action.as_deref() == Some(action.id.as_str())) { if !profile_action.insert((profile.id.as_str(), action.id.as_str())) { errors.push(Diagnostic::error( - "action.grant.duplicate", - "project.accessProfiles[].grants[].action", + "action.permission.duplicate", + "project.accessProfiles[].permissions[].action", "an access profile cannot grant the same action more than once", )); } if profile.anonymous { errors.push(Diagnostic::error( - "action.grant.anonymous_forbidden", - "project.accessProfiles[].grants[].action", + "action.permission.anonymous_forbidden", + "project.accessProfiles[].permissions[].action", "anonymous access profiles cannot invoke immediate actions", )); } if !grant.entity.is_empty() { errors.push(Diagnostic::error( - "action.grant.exclusive", - "project.accessProfiles[].grants[]", - "an access grant must name either one entity or one action", + "action.permission.exclusive", + "project.accessProfiles[].permissions[]", + "an access permission must name either one entity or one action", )); } if grant.operations != BTreeSet::from([Operation::Invoke]) { errors.push(Diagnostic::error( - "action.grant.operation.invalid", - "project.accessProfiles[].grants[].operations", - "immediate-action grants support only the invoke operation", + "action.permission.operation.invalid", + "project.accessProfiles[].permissions[].operations", + "immediate-action permissions support only the invoke operation", )); } - if !entity_grant_fields_empty(grant) { + if !entity_permission_fields_empty(grant) { errors.push(Diagnostic::error( - "action.grant.entity_fields_forbidden", - "project.accessProfiles[].grants[]", - "action grants cannot declare entity projection, query, request, or writable fields", + "action.permission.entity_fields_forbidden", + "project.accessProfiles[].permissions[]", + "action permissions cannot declare entity projection, query, request, or writable fields", )); } - let targets = compile_grant_targets(entities, profile, grant, errors); - validate_grant_covers_uses(&targets, target_uses, errors); + let targets = compile_permission_targets(entities, profile, grant, errors); + validate_permission_covers_uses(&targets, target_uses, errors); for result in &grant.results { if !result_effects.contains(result) { errors.push(Diagnostic::error( - "action.grant.result_unknown", - "project.accessProfiles[].grants[].results", - "action result grants must name declared effect identifiers", + "action.permission.result_unknown", + "project.accessProfiles[].permissions[].results", + "action result permissions must name declared effect identifiers", )); } } - grants.push(CompiledActionGrant { + grants.push(CompiledActionPermission { profile_id: profile.id.clone(), default: profile.default, anonymous: profile.anonymous, + actor_kind: profile.actor_kind, + requester_clients: profile.requester_clients.clone(), principal_claim: profile.principal_claim.clone(), required_scopes: profile.required_scopes.clone(), required_purposes: profile.required_purposes.clone(), @@ -1471,29 +1473,29 @@ fn compile_grants( grants } -fn validate_action_grant_sources( +fn validate_action_permission_sources( actions: &BTreeMap, profiles: &[ProjectAccessProfileSource], errors: &mut Vec, ) { for profile in profiles { - for grant in &profile.grants { + for grant in &profile.permissions { match (grant.entity.is_empty(), grant.action.as_deref()) { (true, None) => errors.push(Diagnostic::error( - "access_profile.grant.target_missing", - "project.accessProfiles[].grants[]", - "an access grant must name either one entity or one action", + "access_profile.permission.target_missing", + "project.accessProfiles[].permissions[]", + "an access permission must name either one entity or one action", )), (false, Some(_)) => errors.push(Diagnostic::error( - "access_profile.grant.target_exclusive", - "project.accessProfiles[].grants[]", - "an access grant must name either one entity or one action", + "access_profile.permission.target_exclusive", + "project.accessProfiles[].permissions[]", + "an access permission must name either one entity or one action", )), (true, Some(action)) if !actions.contains_key(action) => { errors.push(Diagnostic::error( - "action.grant.action_unknown", - "project.accessProfiles[].grants[].action", - "an action grant refers to an unknown action", + "action.permission.action_unknown", + "project.accessProfiles[].permissions[].action", + "an action permission refers to an unknown action", )); } _ => {} @@ -1502,26 +1504,26 @@ fn validate_action_grant_sources( } } -fn compile_grant_targets( +fn compile_permission_targets( entities: &BTreeMap, profile: &ProjectAccessProfileSource, - grant: &crate::contract::AccessGrantSource, + grant: &crate::contract::AccessPermissionSource, errors: &mut Vec, -) -> Vec { +) -> Vec { let mut seen = BTreeSet::new(); let mut targets = Vec::new(); for target in &grant.targets { if !seen.insert(target.entity.as_str()) { errors.push(Diagnostic::error( - "action.grant.target.duplicate", - "project.accessProfiles[].grants[].targets[].entity", - "action target grants must be unique per entity", + "action.permission.target.duplicate", + "project.accessProfiles[].permissions[].targets[].entity", + "action target permissions must be unique per entity", )); } let Some(entity) = entities.get(&target.entity) else { errors.push(Diagnostic::error( - "action.grant.target_unknown", - "project.accessProfiles[].grants[].targets[].entity", + "action.permission.target_unknown", + "project.accessProfiles[].permissions[].targets[].entity", "an action target grant refers to an unknown entity", )); continue; @@ -1529,17 +1531,17 @@ fn compile_grant_targets( validate_row_boundaries( entity, &target.row_boundaries, - "project.accessProfiles[].grants[].targets[].rowBoundaries", + "project.accessProfiles[].permissions[].targets[].rowBoundaries", errors, ); - validate_grant_access_requirements( + validate_permission_access_requirements( entity, profile, &target.row_boundaries, - "project.accessProfiles[].grants[].targets", + "project.accessProfiles[].permissions[].targets", errors, ); - targets.push(CompiledActionTargetGrant { + targets.push(CompiledActionTargetPermission { entity_id: target.entity.clone(), row_boundaries: target.row_boundaries.clone(), }); @@ -1548,8 +1550,8 @@ fn compile_grant_targets( targets } -fn validate_grant_covers_uses( - targets: &[CompiledActionTargetGrant], +fn validate_permission_covers_uses( + targets: &[CompiledActionTargetPermission], target_uses: &[CompiledActionTargetUse], errors: &mut Vec, ) { @@ -1559,9 +1561,9 @@ fn validate_grant_covers_uses( .any(|target| target.entity_id == use_.entity_id) { errors.push(Diagnostic::error( - "action.grant.targets.incomplete", - "project.accessProfiles[].grants[].targets", - "action grants must cover every created, patched, and referenced target entity", + "action.permission.targets.incomplete", + "project.accessProfiles[].permissions[].targets", + "action permissions must cover every created, patched, and referenced target entity", )); } } @@ -1583,7 +1585,7 @@ fn validate_row_boundaries( )) { errors.push(Diagnostic::error( - "action.grant.row_boundary_invalid", + "action.permission.row_boundary_invalid", path, "action target row boundaries must be direct, non-empty, and duplicate-free", )); @@ -1593,7 +1595,7 @@ fn validate_row_boundaries( } let Some(field) = entity.fields.get(&boundary.field) else { errors.push(Diagnostic::error( - "action.grant.row_boundary_field_unknown", + "action.permission.row_boundary_field_unknown", path, "an action target row boundary refers to an unknown field", )); @@ -1604,7 +1606,7 @@ fn validate_row_boundaries( FieldTypeSource::Crs84Point { .. } | FieldTypeSource::Structured { .. } ) { errors.push(Diagnostic::error( - "action.grant.row_boundary_type_unsupported", + "action.permission.row_boundary_type_unsupported", path, "CRS84 point and structured fields cannot be action target row-boundary fields", )); @@ -1612,7 +1614,7 @@ fn validate_row_boundaries( } } -fn validate_grant_access_requirements( +fn validate_permission_access_requirements( entity: &CompiledEntity, profile: &ProjectAccessProfileSource, row_boundaries: &[RowBoundarySource], @@ -1624,6 +1626,9 @@ fn validate_grant_access_requirements( id: profile.id.clone(), default: profile.default, anonymous: profile.anonymous, + actor_kind: profile.actor_kind, + requester_clients: profile.requester_clients.clone(), + task_grant: None, principal_claim: profile.principal_claim.clone(), required_scopes: profile.required_scopes.clone(), required_purposes: profile.required_purposes.clone(), @@ -1658,7 +1663,7 @@ fn compile_action_routes( errors: &mut Vec, ) -> Vec { let route_profiles = action - .grants + .permissions .iter() .filter_map(|grant| { profiles @@ -1728,7 +1733,7 @@ fn route_default_profile<'a>( defaults.first().copied() } -fn entity_grant_fields_empty(grant: &crate::contract::AccessGrantSource) -> bool { +fn entity_permission_fields_empty(grant: &crate::contract::AccessPermissionSource) -> bool { grant.readable_fields.is_empty() && crate::contract::is_default_readable_request_fields(&grant.readable_request_fields) && grant.writable_fields.is_empty() @@ -1756,7 +1761,7 @@ fn contract_fingerprint( Option<&crate::model::CompiledActionHandler>, ), requires: &[CompiledActionRequirement], - grants: &[CompiledActionGrant], + permissions: &[CompiledActionPermission], ) -> String { let target_entities = effects .iter() @@ -1781,7 +1786,7 @@ fn contract_fingerprint( "inputs": inputs, "targetEntities": target_contracts, "effects": effects, - "grants": grants, + "permissions": permissions, "limits": { "maximumTargets": MAX_CHANGE_REQUEST_TARGETS, "maximumFieldMutations": MAX_CHANGE_REQUEST_FIELD_MUTATIONS, diff --git a/crates/registry-breg/src/lib.rs b/crates/registry-breg/src/lib.rs index a05e39a783..18a302f345 100644 --- a/crates/registry-breg/src/lib.rs +++ b/crates/registry-breg/src/lib.rs @@ -121,6 +121,8 @@ pub mod schema; pub mod startup; #[cfg(feature = "runtime")] pub(crate) mod stored_bytes; +#[cfg(feature = "runtime")] +pub mod task_grant; #[cfg(all(feature = "runtime", feature = "tooling"))] pub mod tooling; #[cfg(feature = "runtime")] diff --git a/crates/registry-breg/src/model.rs b/crates/registry-breg/src/model.rs index 7be0b02721..94a59f1043 100644 --- a/crates/registry-breg/src/model.rs +++ b/crates/registry-breg/src/model.rs @@ -131,9 +131,9 @@ pub struct CompiledChangeRequest { pub effects: Vec, pub stages: Vec, pub actions: Vec, - pub review_grants: Vec, - pub apply_grants: Vec, - pub presence_grants: Vec, + pub review_permissions: Vec, + pub apply_permissions: Vec, + pub presence_permissions: Vec, pub target_entities: BTreeSet, pub maximum_targets: u16, pub maximum_field_mutations: u16, @@ -340,7 +340,7 @@ pub struct CompiledChangeRequestActionRoute { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct CompiledChangeRequestReviewGrant { +pub struct CompiledChangeRequestReviewPermission { pub profile_id: String, pub stage: String, pub target_entity_id: String, @@ -350,7 +350,7 @@ pub struct CompiledChangeRequestReviewGrant { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct CompiledChangeRequestApplyGrant { +pub struct CompiledChangeRequestApplyPermission { pub profile_id: String, pub target_entity_id: String, pub row_boundaries: Vec, @@ -358,7 +358,7 @@ pub struct CompiledChangeRequestApplyGrant { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct CompiledChangeRequestPresenceGrant { +pub struct CompiledChangeRequestPresencePermission { pub profile_id: String, pub target_entity_id: String, pub request_row_boundaries: Vec, @@ -397,7 +397,7 @@ pub struct CompiledAction { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub requires: Vec, pub target_uses: Vec, - pub grants: Vec, + pub permissions: Vec, pub result_effects: BTreeSet, pub maximum_targets: u16, pub maximum_field_mutations: u16, @@ -532,22 +532,26 @@ pub struct CompiledActionAccessEntry { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct CompiledActionGrant { +pub struct CompiledActionPermission { pub profile_id: String, pub default: bool, pub anonymous: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_kind: Option, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub requester_clients: BTreeSet, #[serde(skip_serializing_if = "Option::is_none")] pub principal_claim: Option, pub required_scopes: BTreeSet, pub required_purposes: BTreeSet, pub operations: BTreeSet, - pub targets: Vec, + pub targets: Vec, pub results: BTreeSet, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] -pub struct CompiledActionTargetGrant { +pub struct CompiledActionTargetPermission { pub entity_id: String, pub row_boundaries: Vec, } diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 4432383319..4e014c786f 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -1086,6 +1086,7 @@ pub struct MutationCoordinator { attachment_verification: crate::attachment_verification::AttachmentVerification, audit_profile: AuditProfile, event_destinations: Option>, + task_status: Option>, } impl MutationCoordinator { @@ -1115,9 +1116,44 @@ impl MutationCoordinator { attachment_verification: Default::default(), audit_profile, event_destinations, + task_status: None, } } + pub fn with_task_status( + mut self, + checker: Arc, + ) -> Self { + self.task_status = Some(checker); + self + } + + async fn check_task_authority( + &self, + binding: &crate::task_grant::TaskGrantBinding, + ) -> Result<(), MutationError> { + if !binding.is_current() { + return Err(MutationError::PreconditionFailed); + } + let checker = self + .task_status + .as_ref() + .ok_or(MutationError::Unavailable)?; + // Bound all implementations, including injected adapters. The exact + // proposal remains locked through this check and the following commit. + tokio::time::timeout(Duration::from_secs(5), checker.check(binding)) + .await + .map_err(|_| MutationError::Unavailable)? + .map_err(|error| match error { + crate::task_grant::TaskGrantError::Refused => MutationError::PreconditionFailed, + _ => MutationError::Unavailable, + })?; + if !binding.is_current() { + return Err(MutationError::PreconditionFailed); + } + Ok(()) + } + pub(crate) fn with_attachment_storage( mut self, storage: crate::attachment_storage::AttachmentStorage, @@ -1221,6 +1257,9 @@ impl MutationCoordinator { { return Err(MutationError::PreconditionFailed); } + if let Some(grant) = request.claims.task_grant() { + self.check_task_authority(grant).await?; + } crate::attachment_store::stage_external( tx, &crate::attachment_store::content_hash(bytes), @@ -1609,6 +1648,12 @@ impl MutationCoordinator { }); } + if let Some(grant) = request.claims.task_grant() { + if request.plan.entity.change_request.is_none() { + return Err(MutationError::PreconditionFailed); + } + self.check_task_authority(grant).await?; + } fault.fail_at(MutationFaultPoint::BeforeCurrentRow)?; let current = apply_current_row( transaction.transaction(), diff --git a/crates/registry-breg/src/mutation/action.rs b/crates/registry-breg/src/mutation/action.rs index c6d26d45c6..f1f500d508 100644 --- a/crates/registry-breg/src/mutation/action.rs +++ b/crates/registry-breg/src/mutation/action.rs @@ -1211,7 +1211,7 @@ fn validate_action_claims( return Err(MutationError::InvalidRequest); } let grant = action - .grants + .permissions .iter() .find(|grant| grant.profile_id == claims.access_profile()) .ok_or(MutationError::InvalidRequest)?; diff --git a/crates/registry-breg/src/mutation/request.rs b/crates/registry-breg/src/mutation/request.rs index c3d520fe19..f3566e6204 100644 --- a/crates/registry-breg/src/mutation/request.rs +++ b/crates/registry-breg/src/mutation/request.rs @@ -837,6 +837,21 @@ impl MutationCoordinator { { return Err(MutationError::PreconditionFailed); } + if let Some(grant) = claims.task_grant() { + self.check_task_authority(grant).await?; + } + if matches!(input.action, RequestActionBody::Approve { .. }) { + if let Some(grant) = crate::request_store::load_task_authority( + transaction.transaction(), + &entity.id, + record_uuid, + i64::from(workflow.current_version().get()), + ) + .await? + { + self.check_task_authority(&grant).await?; + } + } let previous_revision = i64::try_from(workflow.workflow_revision().get()) .map_err(|_| MutationError::Unavailable)?; let mut save_previous_revision = previous_revision; @@ -1015,6 +1030,18 @@ impl MutationCoordinator { &next, ) .await?; + if matches!(input.action, RequestActionBody::Submit) { + if let Some(grant) = claims.task_grant() { + crate::request_store::save_task_authority( + transaction.transaction(), + &entity.id, + record_uuid, + i64::from(next.current_version().get()), + grant, + ) + .await?; + } + } save_previous_revision = i64::try_from(next.workflow_revision().get()) .map_err(|_| MutationError::Unavailable)?; if let Some(targets) = prepared_targets.as_deref() { @@ -1101,6 +1128,18 @@ impl MutationCoordinator { &next, ) .await?; + if matches!(input.action, RequestActionBody::Submit) { + if let Some(grant) = claims.task_grant() { + crate::request_store::save_task_authority( + transaction.transaction(), + &entity.id, + record_uuid, + i64::from(next.current_version().get()), + grant, + ) + .await?; + } + } crate::request_store::link_request_revision( transaction.transaction(), &entity.id, @@ -1381,6 +1420,17 @@ impl MutationCoordinator { { return Err(MutationError::PreconditionFailed); } + if let Some(grant) = crate::request_store::load_task_authority( + transaction.transaction(), + &entity.id, + Uuid::parse_str(workflow.request().record_id().as_str()) + .map_err(|_| MutationError::InvalidRequest)?, + i64::from(proposal_version), + ) + .await? + { + self.check_task_authority(&grant).await?; + } let loaded_targets; let targets = if let Some(targets) = targets_override { targets diff --git a/crates/registry-breg/src/package/tests/immediate_actions.rs b/crates/registry-breg/src/package/tests/immediate_actions.rs index 74359182eb..c528d4f87b 100644 --- a/crates/registry-breg/src/package/tests/immediate_actions.rs +++ b/crates/registry-breg/src/package/tests/immediate_actions.rs @@ -15,7 +15,7 @@ fn source() -> Value { {"id":"label", "apiName":"newLabel", "type":"string", "maxLength":40, "required":true, "classification":"internal"} ], "effects":[{"id":"renamed", "target":{"fromField":"item"}, "operation":"patch", "set":{"label":{"fromField":"label"}}}]}], "accessProfiles":[{"id":"operator", "default":true, "principalClaim":"principal", "requiredScopes":["item.rename"], - "grants":[{"action":"rename-item", "operations":["invoke"], "targets":[{"entity":"item", "rowBoundaries":[]}], "results":["renamed"]}]}] + "permissions":[{"action":"rename-item", "operations":["invoke"], "targets":[{"entity":"item", "rowBoundaries":[]}], "results":["renamed"]}]}] }) } @@ -40,7 +40,7 @@ fn action_configuration_and_disclosure_changes_are_visible_in_package_diffs() { let mut scoped = value.clone(); scoped["accessProfiles"][0]["requiredScopes"] = json!(["item.rename.restricted"]); let mut no_results = value; - no_results["accessProfiles"][0]["grants"][0]["results"] = json!([]); + no_results["accessProfiles"][0]["permissions"][0]["results"] = json!([]); for variant in [renamed, scoped, no_results] { let after = compile(&variant); let changes = compiled_registry_change_set(&before, &after, "prior-package"); @@ -148,7 +148,7 @@ fn reviewed_successor_does_not_duplicate_action_policies_for_new_entity() { "id":"task-operator", "default":true, "principalClaim":"principal", - "grants":[{"action":"create-task", "operations":["invoke"], "targets":[{"entity":"task", "rowBoundaries":[]}], "results":["task"]}] + "permissions":[{"action":"create-task", "operations":["invoke"], "targets":[{"entity":"task", "rowBoundaries":[]}], "results":["task"]}] }]); let after = compile(&candidate); let plan = reviewed_plan(&before, &after); @@ -229,7 +229,7 @@ fn reviewed_successor_tracks_link_only_reference_policies() { "required":true, "classification":"internal" })); with_action["actions"][0]["effects"][0]["set"]["group"] = json!({"fromField":"group"}); - with_action["accessProfiles"][0]["grants"][0]["targets"] + with_action["accessProfiles"][0]["permissions"][0]["targets"] .as_array_mut() .unwrap() .push(json!({ diff --git a/crates/registry-breg/src/postgres/context.rs b/crates/registry-breg/src/postgres/context.rs index 69f1763aed..8de40c34a6 100644 --- a/crates/registry-breg/src/postgres/context.rs +++ b/crates/registry-breg/src/postgres/context.rs @@ -195,6 +195,7 @@ pub struct ClaimContext { row_boundaries: Vec, canonical_row_boundaries: String, submitter_targets: BTreeMap, + task_grant: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -220,6 +221,23 @@ impl SpatialBboxContext { } impl ClaimContext { + pub(crate) fn with_task_grant( + mut self, + grant: crate::task_grant::TaskGrantBinding, + ) -> Result { + if self.principal.as_deref() != Some(grant.principal()) + || self.purpose.as_deref() != Some(grant.purpose()) + { + return Err(invalid_context()); + } + self.task_grant = Some(grant); + Ok(self) + } + + pub fn task_grant(&self) -> Option<&crate::task_grant::TaskGrantBinding> { + self.task_grant.as_ref() + } + pub fn for_compiled( registry: &CompiledRegistry, entity_id: &str, @@ -292,6 +310,7 @@ impl ClaimContext { row_boundaries, canonical_row_boundaries, submitter_targets: BTreeMap::new(), + task_grant: None, }) } @@ -323,6 +342,7 @@ impl ClaimContext { row_boundaries, canonical_row_boundaries, submitter_targets: BTreeMap::new(), + task_grant: None, }) } @@ -722,7 +742,7 @@ impl ChangeRequestPresenceContext { .as_ref() .ok_or_else(invalid_context)?; let compiled_grant = plan - .presence_grants + .presence_permissions .iter() .find(|grant| { grant.profile_id == target_claims.access_profile() @@ -1062,7 +1082,7 @@ impl ChangeRequestTargetContext { let expected = match review_stage { Some(stage) => { let grant = plan - .review_grants + .review_permissions .iter() .find(|grant| { grant.profile_id == claims.access_profile() @@ -1077,7 +1097,7 @@ impl ChangeRequestTargetContext { } None => { &plan - .apply_grants + .apply_permissions .iter() .find(|grant| { grant.profile_id == claims.access_profile() @@ -1209,7 +1229,7 @@ impl ChangeRequestTargetContext { return Err(invalid_context()); } let grant = plan - .review_grants + .review_permissions .iter() .find(|grant| { grant.profile_id == request_claims.access_profile() @@ -1235,7 +1255,7 @@ impl ChangeRequestTargetContext { return Err(invalid_context()); } let grant = plan - .apply_grants + .apply_permissions .iter() .find(|grant| { grant.profile_id == request_claims.access_profile() @@ -1453,7 +1473,7 @@ impl ImmediateActionTargetContext { } validate_action_effect_binding(&effects, &binding)?; let grant = action - .grants + .permissions .iter() .find(|grant| { grant.profile_id == action_claims.access_profile() @@ -1719,7 +1739,7 @@ impl ImmediateActionLinkContext { return Err(invalid_context()); } let grant = action - .grants + .permissions .iter() .find(|grant| { grant.profile_id == action_claims.access_profile() @@ -2546,7 +2566,7 @@ mod tests { use crate::compiler::{compile_project, CompileProfile}; use crate::contract::{ - parse_project_json, AccessGrantSource, Classification, EntitySource, FieldSource, + parse_project_json, AccessPermissionSource, Classification, EntitySource, FieldSource, FieldTypeSource, MutationMode, Operation, ProjectAccessProfileSource, RegistryProject, RowBoundarySource, }; @@ -2743,7 +2763,7 @@ mod tests { vec![equals("jurisdiction", "zone-a")], binding.clone(), ) - .expect("grouped action target context derives from compiled action grant"); + .expect("grouped action target context derives from compiled action permission"); assert!(context .canonical_context() .contains("\"effectIds\":[\"household-code-update\",\"household-note-update\"]")); @@ -3150,7 +3170,7 @@ mod tests { ], "accessProfiles":[{ "id":"typed","default":true,"principalClaim":"registry_principal", - "grants":[ + "permissions":[ { "entity":"parent-entry","operations":["get"],"readableFields":["name"], "rowBoundaries": [] @@ -3251,10 +3271,13 @@ mod tests { id: "operator".to_owned(), default: true, anonymous: false, + actor_kind: None, + requester_clients: BTreeSet::new(), + task_grant: None, principal_claim: Some("registry_principal".to_owned()), required_scopes: BTreeSet::new(), required_purposes: BTreeSet::from(["operations".to_owned()]), - grants: vec![AccessGrantSource { + permissions: vec![AccessPermissionSource { membership_boundaries: Vec::new(), entity: "entry".to_owned(), action: None, @@ -3300,10 +3323,13 @@ mod tests { id: "viewer".to_owned(), default: false, anonymous: false, + actor_kind: None, + requester_clients: BTreeSet::new(), + task_grant: None, principal_claim: Some("registry_principal".to_owned()), required_scopes: BTreeSet::new(), required_purposes: BTreeSet::new(), - grants: vec![AccessGrantSource { + permissions: vec![AccessPermissionSource { membership_boundaries: Vec::new(), entity: "entry".to_owned(), action: None, @@ -3377,7 +3403,7 @@ mod tests { "default":true, "principalClaim":"registry_principal", "requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"rename-household-local", "operations":["invoke"], "targets":[{"entity":"household","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}], @@ -3434,7 +3460,7 @@ mod tests { "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-placement", "operations":["get","list"], "readableFields":["tenant","site"], @@ -3444,7 +3470,7 @@ mod tests { }, { "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["create","get","list","patch","submit_request","revise_request"], "readableFields":["placement","proposed-site","reason"], @@ -3454,7 +3480,7 @@ mod tests { }, { "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["placement","proposed-site","reason"], @@ -3471,7 +3497,7 @@ mod tests { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["placement","proposed-site","reason"], diff --git a/crates/registry-breg/src/postgres/mutation.rs b/crates/registry-breg/src/postgres/mutation.rs index d2fea32860..6ba5a1b3c9 100644 --- a/crates/registry-breg/src/postgres/mutation.rs +++ b/crates/registry-breg/src/postgres/mutation.rs @@ -46,6 +46,15 @@ pub struct PostgresRecordMutationService { } impl PostgresRecordMutationService { + #[must_use] + pub fn with_task_status( + mut self, + checker: Arc, + ) -> Self { + self.coordinator = self.coordinator.with_task_status(checker); + self + } + #[must_use] pub fn with_attachment_storage( mut self, @@ -668,6 +677,10 @@ fn strict_claim_context( row_boundaries, ) .and_then(|claims| claims.with_api_submitter_targets(registry, context)) + .and_then(|claims| match context.task_grant() { + Some(grant) => claims.with_task_grant(grant.clone()), + None => Ok(claims), + }) .map_err(|_| MutationError::InvalidRequest) } diff --git a/crates/registry-breg/src/postgres/read.rs b/crates/registry-breg/src/postgres/read.rs index c5f82230db..371ea63122 100644 --- a/crates/registry-breg/src/postgres/read.rs +++ b/crates/registry-breg/src/postgres/read.rs @@ -3156,7 +3156,7 @@ mod tests { }], "accessProfiles":[{ "id":"public","default":true,"anonymous":true, - "grants":[{"entity":"site","operations":["get","list"],"readableFields":["code","location"], "rowBoundaries": []}] + "permissions":[{"entity":"site","operations":["get","list"],"readableFields":["code","location"], "rowBoundaries": []}] }] }"#, ) @@ -3206,7 +3206,7 @@ mod tests { }], "accessProfiles":[{ "id":"public","default":true,"anonymous":true, - "grants":[{ + "permissions":[{ "entity":"site", "operations":["list"], "readableFields":["code","location"], @@ -3289,7 +3289,7 @@ mod tests { }], "accessProfiles":[{ "id":"public","default":true,"anonymous":true, - "grants":[{ + "permissions":[{ "entity":"case","operations":["list"], "readableFields":["label"],"filterableFields":["label"],"sortableFields":["label"], "rowBoundaries": [] @@ -3557,7 +3557,7 @@ mod tests { }], "accessProfiles":[{ "id":"public","default":true,"anonymous":true, - "grants":[{ + "permissions":[{ "entity":"site", "operations":["list"], "readableFields":["code","location"], diff --git a/crates/registry-breg/src/postgres/request_read.rs b/crates/registry-breg/src/postgres/request_read.rs index bb59c72e8e..47eb0ddb15 100644 --- a/crates/registry-breg/src/postgres/request_read.rs +++ b/crates/registry-breg/src/postgres/request_read.rs @@ -501,7 +501,7 @@ pub(super) async fn attachment_version_is_authorized( continue; } } else if entity.change_request.as_ref().is_some_and(|plan| { - plan.apply_grants.iter().any(|grant| { + plan.apply_permissions.iter().any(|grant| { grant.profile_id == claims.access_profile() && grant.target_entity_id == entity.id }) }) { @@ -2066,17 +2066,17 @@ mod tests { "fields":[{"id":"label","type":"string","maxLength":64,"classification":"internal"}] }], "accessProfiles":[{ - "id":"reader","principalClaim":"principal","grants":[{ + "id":"reader","principalClaim":"principal","permissions":[{ "entity":"request","operations":["get"],"readableFields":["label"], "rowBoundaries": [] }] },{ - "id":"empty-editor","principalClaim":"principal","grants":[{ + "id":"empty-editor","principalClaim":"principal","permissions":[{ "entity":"request","operations":["get","patch"],"readableFields":["label"], "rowBoundaries": [] }] },{ - "id":"editor","default":true,"principalClaim":"principal","grants":[{ + "id":"editor","default":true,"principalClaim":"principal","permissions":[{ "entity":"request","operations":["get","patch"],"readableFields":["label"],"writableFields":["label"], "rowBoundaries": [] }] @@ -2624,7 +2624,7 @@ mod tests { ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"target","operations":["get"],"readableFields":["proposed-site"], "rowBoundaries": [] }] diff --git a/crates/registry-breg/src/request_prepare/tests.rs b/crates/registry-breg/src/request_prepare/tests.rs index d1be528bcc..0b60155a1e 100644 --- a/crates/registry-breg/src/request_prepare/tests.rs +++ b/crates/registry-breg/src/request_prepare/tests.rs @@ -34,7 +34,7 @@ fn fixture(effects: Value) -> CompiledRegistry { ], "changeRequest":{"effects":effects,"review":{"stages":[{"id":"review","approvals":1}]}} }], - "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","grants":[{ + "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","permissions":[{ "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["one","two","value"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["first","second","parent"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"target", "rowBoundaries": []}], @@ -137,7 +137,7 @@ fn declarative_and_rhai_paths_produce_byte_equivalent_canonical_effects() { ], "changeRequest":change_request }], - "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","grants":[{ + "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","permissions":[{ "entity":"request","operations":["get","submit_request","approve_request","apply_request"], "readableFields":["one","value"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["first"], "rowBoundaries": []}]}], @@ -396,7 +396,7 @@ fn rhai_planner_refuses_authority_ceiling_escape_before_target_locks() { "review":{"stages":[{"id":"review","approvals":1}]} } }], - "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","grants":[{ + "accessProfiles":[{"id":"submitter","default":true,"principalClaim":"sub","permissions":[{ "entity":"request","operations":["get","submit_request","approve_request","apply_request"], "readableFields":["target-ref","value"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["allowed"], "rowBoundaries": []}]}], diff --git a/crates/registry-breg/src/request_retention.rs b/crates/registry-breg/src/request_retention.rs index 45516eb8af..727e3d7b0f 100644 --- a/crates/registry-breg/src/request_retention.rs +++ b/crates/registry-breg/src/request_retention.rs @@ -1239,6 +1239,20 @@ async fn erase_request_detail_in_transaction( .filter(|entity| entity.change_request.is_some()) .ok_or(RequestRetentionError::Unavailable)?; + // Task selectors are proposal detail and must disappear in the same + // maintenance transaction as the frozen proposal payload. + transaction + .execute( + "DELETE FROM registry_internal.registry_request_task_authority + WHERE request_entity_id = $1 AND request_id = $2 AND proposal_version = $3", + &[ + &scope.request_entity_id, + &scope.request_id, + &scope.proposal_version, + ], + ) + .await + .map_err(map_retention_error)?; let proposal_snapshots = transaction .execute( "UPDATE registry_internal.registry_request_proposals @@ -1646,7 +1660,7 @@ fn request_row_boundary_fields(entity: &CompiledEntity) -> BTreeSet { }) .collect::>(); if let Some(request) = &entity.change_request { - for grant in &request.presence_grants { + for grant in &request.presence_permissions { fields.extend( grant .request_row_boundaries diff --git a/crates/registry-breg/src/request_store.rs b/crates/registry-breg/src/request_store.rs index 3b293f0656..6f9929d95f 100644 --- a/crates/registry-breg/src/request_store.rs +++ b/crates/registry-breg/src/request_store.rs @@ -29,6 +29,7 @@ pub(crate) const REQUEST_TABLES: &[(&str, &[&str])] = &[ &["DELETE", "INSERT", "SELECT"], ), ("registry_request_proposals", &["INSERT", "SELECT"]), + ("registry_request_task_authority", &["INSERT", "SELECT"]), ("registry_request_targets", &["INSERT", "SELECT"]), ("registry_request_decisions", &["INSERT", "SELECT"]), ("registry_request_applications", &["INSERT", "SELECT"]), @@ -90,6 +91,17 @@ pub(crate) async fn install( CHECK ((snapshot IS NULL) = (erased_at IS NOT NULL)), CHECK (snapshot IS NULL OR jsonb_typeof(snapshot) = 'object') ); + CREATE TABLE IF NOT EXISTS registry_internal.registry_request_task_authority ( + request_entity_id text NOT NULL, + request_id uuid NOT NULL, + proposal_version bigint NOT NULL, + binding jsonb NOT NULL CHECK ( + jsonb_typeof(binding) = 'object' AND octet_length(binding::text) <= 65536 + ), + PRIMARY KEY (request_entity_id, request_id, proposal_version), + FOREIGN KEY (request_entity_id, request_id, proposal_version) + REFERENCES registry_internal.registry_request_proposals + ); CREATE TABLE IF NOT EXISTS registry_internal.registry_request_targets ( request_entity_id text NOT NULL, request_id uuid NOT NULL, @@ -257,6 +269,60 @@ pub(crate) async fn install( Ok(()) } +/// Retain verified submitter authority separately from all later reviewers. +/// The proposal foreign key binds these claims to its immutable effect digest. +pub(crate) async fn save_task_authority( + transaction: &Transaction<'_>, + entity_id: &str, + record_id: Uuid, + proposal_version: i64, + binding: &crate::task_grant::TaskGrantBinding, +) -> Result<(), MutationError> { + binding + .validate() + .map_err(|_| MutationError::PreconditionFailed)?; + let value = serde_json::to_value(binding).map_err(|_| MutationError::Unavailable)?; + transaction + .execute( + "INSERT INTO registry_internal.registry_request_task_authority + (request_entity_id, request_id, proposal_version, binding) + VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + &[&entity_id, &record_id, &proposal_version, &value], + ) + .await + .map_err(|_| MutationError::Unavailable)?; + let stored = load_task_authority(transaction, entity_id, record_id, proposal_version).await?; + if stored.as_ref() != Some(binding) { + return Err(MutationError::IdempotencyConflict); + } + Ok(()) +} + +/// Call only after proving current visibility of the corresponding request. +pub(crate) async fn load_task_authority( + transaction: &Transaction<'_>, + entity_id: &str, + record_id: Uuid, + proposal_version: i64, +) -> Result, MutationError> { + let row = transaction + .query_opt( + "SELECT binding FROM registry_internal.registry_request_task_authority + WHERE request_entity_id = $1 AND request_id = $2 AND proposal_version = $3", + &[&entity_id, &record_id, &proposal_version], + ) + .await + .map_err(|_| MutationError::Unavailable)?; + row.map(|row| { + let binding: crate::task_grant::TaskGrantBinding = + serde_json::from_value(row.get::<_, Value>(0)) + .map_err(|_| MutationError::Unavailable)?; + binding.validate().map_err(|_| MutationError::Unavailable)?; + Ok(binding) + }) + .transpose() +} + #[derive(Clone, Eq, PartialEq)] #[allow(dead_code)] pub(crate) struct RequestWorkflowHeader { @@ -1561,6 +1627,60 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn proposal_task_authority_is_immutable_and_separate_from_reviewer() { + let (database, mut migration, migration_task) = install_schema().await; + let request_id = Uuid::new_v4(); + let submitted = workflow(request_id) + .submit( + context("submitter", 1), + proposal(Uuid::new_v4(), "site-a", "site-b"), + ) + .expect("submit") + .into_workflow(); + let grant: crate::task_grant::TaskGrantBinding = serde_json::from_value(json!({ + "grantId": Uuid::new_v4().to_string(), "authority": "casework", + "sourceIssuer": "https://casework.test", "principal": "original-agent", + "client": "original-client", "resource": "urn:breg:test", "purpose": "review", + "bounds": {"type":"breg", "permissions":[{"collection":"people", "operations":["get","patch"]}]}, + "subjects": {"person_reference":"synthetic-person"}, + "expiresAt": chrono::Utc::now().timestamp() + 900, + })).expect("binding"); + let tx = migration.transaction().await.expect("transaction"); + initialize_draft(&tx, REQUEST_ENTITY, request_id, "submitter") + .await + .expect("draft"); + save(&tx, REQUEST_ENTITY, request_id, 1, &submitted) + .await + .expect("proposal"); + save_task_authority(&tx, REQUEST_ENTITY, request_id, 1, &grant) + .await + .expect("bind"); + save_task_authority(&tx, REQUEST_ENTITY, request_id, 1, &grant) + .await + .expect("exact replay"); + let reviewed = request_revision(submitted, "human-reviewer", 2); + save(&tx, REQUEST_ENTITY, request_id, 2, &reviewed) + .await + .expect("review"); + assert_eq!( + load_task_authority(&tx, REQUEST_ENTITY, request_id, 1) + .await + .unwrap(), + Some(grant.clone()) + ); + let mut changed = serde_json::to_value(&grant).unwrap(); + changed["client"] = json!("replacement-client"); + let changed = serde_json::from_value(changed).unwrap(); + assert_eq!( + save_task_authority(&tx, REQUEST_ENTITY, request_id, 1, &changed).await, + Err(MutationError::IdempotencyConflict) + ); + tx.commit().await.expect("commit"); + migration_task.abort(); + database.cleanup().await; + } + #[tokio::test] async fn reviewer_reasons_upgrade_roundtrip_and_refuse_immutable_mismatch() { let (database, mut migration, migration_task) = install_schema().await; diff --git a/crates/registry-breg/src/runtime_config.rs b/crates/registry-breg/src/runtime_config.rs index 7398c3b9bc..bb22e5ab23 100644 --- a/crates/registry-breg/src/runtime_config.rs +++ b/crates/registry-breg/src/runtime_config.rs @@ -2,7 +2,7 @@ //! Strict deployment-only runtime configuration for Base Registry Engine. use std::{ - collections::HashSet, + collections::{BTreeMap, HashSet}, fmt, fs, io::Read, net::{IpAddr, SocketAddr}, @@ -24,8 +24,8 @@ use registry_platform_httputil::destination::{ MAX_DESTINATION_ORIGIN_URL_BYTES, MAX_DESTINATION_PRIVATE_CIDRS, MAX_DESTINATION_TARGET_BYTES, }; use registry_platform_oidc::{ - access_token_typ_set, fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, - TokenVerifierConfig, + access_token_typ_set, fetch_discovery, ClaimNames, JwksFetcher, JwksFetcherConfig, + OidcDiscoveryConfig, TokenVerifierConfig, }; use serde::Deserialize; use serde_json::{Map, Value}; @@ -379,6 +379,7 @@ pub struct RuntimeConfig { attachment_verification: crate::attachment_verification::AttachmentVerificationConfig, package: PackageConfig, authentication: AuthenticationConfig, + task_grant_status: Vec, audit: AuditConfig, cursor: CursorConfig, event_destinations: EventDestinationConfigs, @@ -433,6 +434,7 @@ impl RuntimeConfig { attachment_verification, package, authentication, + task_grant_status: raw.task_grant_status, audit, cursor, event_destinations, @@ -443,6 +445,30 @@ impl RuntimeConfig { }) } + pub fn activate_task_status( + &self, + compiled: &CompiledRegistry, + ) -> Result> { + let status = crate::task_grant::TaskGrantStatusRegistry::activate( + &self.task_grant_status, + &self.authentication.oidc.audience, + &self.secret_resolver()?, + ) + .map_err(|_| RuntimeConfigError::InvalidBinding)?; + for profile in compiled + .entities() + .values() + .flat_map(|entity| entity.access_profiles.values()) + { + if let Some(grant) = &profile.task_grant { + if !status.contains(&grant.authority, &grant.source_issuer) { + return Err(RuntimeConfigError::InvalidBinding); + } + } + } + Ok(Arc::new(status)) + } + pub fn activate_evidence( &self, compiled: &CompiledRegistry, @@ -1597,6 +1623,8 @@ impl fmt::Debug for JwksCacheConfig { pub struct AuthorityClaimsConfig { principal: String, purpose: Option, + contextual: ClaimNames, + trusted_actors: BTreeMap, } impl AuthorityClaimsConfig { @@ -1614,14 +1642,33 @@ impl AuthorityClaimsConfig { return Err(RuntimeConfigError::InvalidOidc); } } + let contextual = raw.contextual.map(ClaimNames::from).unwrap_or_default(); + contextual + .validate() + .map_err(|_| RuntimeConfigError::InvalidOidc)?; + if raw.trusted_actors.len() > MAX_LIST_ITEMS + || raw.trusted_actors.iter().any(|(client, actor)| { + client.is_empty() + || client.len() > MAX_LIST_VALUE_BYTES + || actor.is_empty() + || actor.len() > MAX_LIST_VALUE_BYTES + || client.chars().any(char::is_whitespace) + || actor.chars().any(char::is_whitespace) + }) + { + return Err(RuntimeConfigError::InvalidOidc); + } Ok(Self { principal: raw.principal, purpose: raw.purpose, + contextual, + trusted_actors: raw.trusted_actors, }) } fn to_platform_config(&self) -> AuthorityClaimConfig { AuthorityClaimConfig::new(self.principal.clone(), self.purpose.clone()) + .with_contextual_claims(self.contextual.clone(), self.trusted_actors.clone()) } } @@ -1631,6 +1678,8 @@ impl fmt::Debug for AuthorityClaimsConfig { .debug_struct("AuthorityClaimsConfig") .field("principal", &"") .field("purpose", &self.purpose.as_ref().map(|_| "")) + .field("contextual", &self.contextual) + .field("trusted_actor_clients", &self.trusted_actors.keys()) .finish() } } @@ -1805,6 +1854,8 @@ struct RawRuntimeConfig { attachment_verification: crate::attachment_verification::RawAttachmentVerificationConfig, package: RawPackageConfig, authentication: RawAuthenticationConfig, + #[serde(default)] + task_grant_status: Vec, audit: RawAuditConfig, cursor: RawCursorConfig, #[serde(default)] @@ -2008,6 +2059,43 @@ struct RawAuthorityClaimsConfig { principal: String, #[serde(default)] purpose: Option, + #[serde(default)] + contextual: Option, + #[serde(default)] + trusted_actors: BTreeMap, +} + +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RawContextualClaimNames { + actor_kind: String, + purpose: String, + grant_id: String, + grant_authority: String, + grant_source_issuer: String, + grant_client: String, + grant_resource: String, + grant_exp: String, + grant_bounds: String, + approver: String, +} + +impl From for ClaimNames { + fn from(value: RawContextualClaimNames) -> Self { + Self { + actor_kind: value.actor_kind, + purpose: value.purpose, + grant_id: value.grant_id, + grant_authority: value.grant_authority, + grant_source_issuer: value.grant_source_issuer, + grant_client: value.grant_client, + grant_resource: value.grant_resource, + grant_exp: value.grant_exp, + grant_bounds: value.grant_bounds, + approver: value.approver, + } + } } #[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] diff --git a/crates/registry-breg/src/schema.rs b/crates/registry-breg/src/schema.rs index 7bff1430cd..6a187a2541 100644 --- a/crates/registry-breg/src/schema.rs +++ b/crates/registry-breg/src/schema.rs @@ -420,12 +420,12 @@ mod tests { assert!(!schema.is_valid(&old_purposes)); let mut old_actions = fixture("asset-site-placement"); - let operations = old_actions["accessProfiles"][0]["grants"][0] + let operations = old_actions["accessProfiles"][0]["permissions"][0] .as_object_mut() - .expect("access grant is an object") + .expect("access permission is an object") .remove("operations") .expect("fixture uses canonical operations"); - old_actions["accessProfiles"][0]["grants"][0]["actions"] = operations; + old_actions["accessProfiles"][0]["permissions"][0]["actions"] = operations; assert!(!schema.is_valid(&old_actions)); } @@ -459,11 +459,11 @@ mod tests { "classification":"internal" })); instance["entities"][0]["geojson"] = serde_json::json!({"geometryField":"location"}); - instance["accessProfiles"][0]["grants"][0]["readableFields"] + instance["accessProfiles"][0]["permissions"][0]["readableFields"] .as_array_mut() .unwrap() .push(Value::String("location".to_owned())); - instance["accessProfiles"][0]["grants"][0]["spatialQueries"] = serde_json::json!({ + instance["accessProfiles"][0]["permissions"][0]["spatialQueries"] = serde_json::json!({ "bbox":{ "maximumLongitudeSpanDegrees":0.25, "maximumLatitudeSpanDegrees":1.5 @@ -471,8 +471,8 @@ mod tests { }); assert!(schema.is_valid(&instance)); - instance["accessProfiles"][0]["grants"][0]["spatialQueries"]["bbox"]["geometryField"] = - Value::String("location".to_owned()); + instance["accessProfiles"][0]["permissions"][0]["spatialQueries"]["bbox"] + ["geometryField"] = Value::String("location".to_owned()); assert!(!schema.is_valid(&instance)); } diff --git a/crates/registry-breg/src/startup.rs b/crates/registry-breg/src/startup.rs index 98fa9b0aeb..d3b782c001 100644 --- a/crates/registry-breg/src/startup.rs +++ b/crates/registry-breg/src/startup.rs @@ -807,6 +807,9 @@ async fn finish_prepared_server( let evidence = config .activate_evidence(®istry) .map_err(StartupError::RuntimeConfig)?; + let task_status = config + .activate_task_status(®istry) + .map_err(StartupError::RuntimeConfig)?; let attachment_verification_worker = if matches!( attachment_verification, crate::attachment_verification::AttachmentVerification::Disabled @@ -832,6 +835,7 @@ async fn finish_prepared_server( audit_profile, Some(event_destinations), ) + .with_task_status(task_status) .with_attachment_storage(attachment_storage) .with_attachment_verification(attachment_verification); let mutations = Arc::new(match evidence { diff --git a/crates/registry-breg/src/task_grant.rs b/crates/registry-breg/src/task_grant.rs new file mode 100644 index 0000000000..be9f528b4a --- /dev/null +++ b/crates/registry-breg/src/task_grant.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Immutable task authority retained separately from a later human reviewer. +use registry_platform_httputil::{ + client::{build_client, OutboundOptions, PrivateKeyJwt, ServiceBaseUrl, TokenProvider}, + read_bounded, validate_response_headers, +}; +use registry_platform_oidc::{GrantBounds, GrantClaims}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::BTreeMap, fmt, sync::Arc, time::Duration}; +use thiserror::Error; +use uuid::Uuid; +mod config; +pub use config::{TaskGrantStatusConfig, TaskGrantStatusRegistry}; + +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantBinding { + grant_id: String, + authority: String, + source_issuer: String, + principal: String, + client: String, + resource: String, + purpose: String, + bounds: GrantBounds, + subjects: BTreeMap, + expires_at: u64, +} +impl fmt::Debug for TaskGrantBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskGrantBinding()") + } +} +impl TaskGrantBinding { + /// The caller must first verify the JWT, grant context, and selected profile. + /// Preserve all immutable identity members, while granting only the selected + /// profile's own mapped permissions. + pub(crate) fn from_verified( + grant: &GrantClaims, + subjects: BTreeMap, + ) -> Result { + let binding = Self { + grant_id: grant.id().into(), + authority: grant.authority().into(), + source_issuer: grant.source_issuer().into(), + principal: grant.principal().into(), + client: grant.client().into(), + resource: grant.resource().into(), + purpose: grant.purpose().into(), + bounds: grant.bounds().clone(), + subjects, + expires_at: grant.exp(), + }; + binding.validate()?; + Ok(binding) + } + pub(crate) fn validate(&self) -> Result<(), TaskGrantError> { + if Uuid::parse_str(&self.grant_id).is_err() + || self.bounds.breg_permissions().is_none() + || self.subjects.is_empty() + || self.subjects.len() > 32 + || self.subjects.iter().any(|(key, value)| { + key.is_empty() + || key.len() > 128 + || key.chars().any(char::is_control) + || match value { + Value::String(value) => { + value.is_empty() + || value.len() > 512 + || value.chars().any(char::is_control) + } + Value::Bool(_) => false, + Value::Number(value) => { + value.as_i64().is_none() && value.as_u64().is_none() + } + _ => true, + } + }) + { + return Err(TaskGrantError::Refused); + } + Ok(()) + } + pub fn grant_id(&self) -> &str { + &self.grant_id + } + pub fn authority(&self) -> &str { + &self.authority + } + pub fn source_issuer(&self) -> &str { + &self.source_issuer + } + pub fn principal(&self) -> &str { + &self.principal + } + pub fn client(&self) -> &str { + &self.client + } + pub fn resource(&self) -> &str { + &self.resource + } + pub fn purpose(&self) -> &str { + &self.purpose + } + pub fn bounds(&self) -> &GrantBounds { + &self.bounds + } + pub fn subjects(&self) -> &BTreeMap { + &self.subjects + } + pub fn expires_at(&self) -> u64 { + self.expires_at + } + pub(crate) fn is_current(&self) -> bool { + u64::try_from(chrono::Utc::now().timestamp()).is_ok_and(|now| now < self.expires_at) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Error)] +pub enum TaskGrantError { + #[error("task authority was refused")] + Refused, + #[error("task authority status is unavailable")] + Unavailable, + #[error("task authority status configuration is invalid")] + Configuration, +} + +pub trait TaskGrantStatusChecker: Send + Sync { + /// Each invocation must perform a fresh check. Never cache positive status. + fn check<'a>( + &'a self, + binding: &'a TaskGrantBinding, + ) -> std::pin::Pin> + Send + 'a>>; +} + +pub struct TaskGrantStatusClient { + authority: String, + source_issuer: String, + resource: String, + base: ServiceBaseUrl, + http: reqwest::Client, + token: Arc, +} +impl fmt::Debug for TaskGrantStatusClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskGrantStatusClient()") + } +} +impl TaskGrantStatusClient { + pub fn new( + authority: String, + source_issuer: String, + resource: String, + base: reqwest::Url, + token: Arc, + trusted_roots: Option<&[u8]>, + ) -> Result { + if authority.is_empty() + || authority.len() > 512 + || !registry_platform_httputil::valid_resource_uri(&source_issuer) + || !registry_platform_httputil::valid_resource_uri(&resource) + { + return Err(TaskGrantError::Configuration); + } + let base = ServiceBaseUrl::new(base).map_err(|_| TaskGrantError::Configuration)?; + let http = build_client(OutboundOptions { + request_timeout: Duration::from_secs(5), + connect_timeout: Duration::from_secs(3), + user_agent: Some("registry-breg-task-status"), + trusted_root_certificates: trusted_roots, + }) + .map_err(|_| TaskGrantError::Configuration)?; + Ok(Self { + authority, + source_issuer, + resource, + base, + http, + token, + }) + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StatusResponse { + active: bool, + #[serde(default)] + grant: Option, +} +impl TaskGrantStatusChecker for TaskGrantStatusClient { + fn check<'a>( + &'a self, + binding: &'a TaskGrantBinding, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + binding.validate()?; + if !binding.is_current() + || binding.authority != self.authority + || binding.source_issuer != self.source_issuer + || binding.resource != self.resource + { + return Err(TaskGrantError::Refused); + } + let token = self + .token + .bearer_token() + .await + .map_err(|_| TaskGrantError::Unavailable)?; + let url = registry_platform_httputil::url::append_path_segments( + self.base.as_url(), + &["v1", "task-grants", &binding.grant_id, "status"], + ) + .map_err(|_| TaskGrantError::Configuration)?; + let response = self + .http + .get(url) + .header( + reqwest::header::AUTHORIZATION, + token.authorization_header_value(), + ) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|_| TaskGrantError::Unavailable)?; + validate_response_headers(response.headers()) + .map_err(|_| TaskGrantError::Unavailable)?; + if matches!(response.status().as_u16(), 401 | 403 | 404) { + return Err(TaskGrantError::Refused); + } + if response.status() != reqwest::StatusCode::OK + || response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_none_or(|value| !value.trim().eq_ignore_ascii_case("application/json")) + { + return Err(TaskGrantError::Unavailable); + } + let bytes = read_bounded(response, 64 * 1024) + .await + .map_err(|_| TaskGrantError::Unavailable)?; + let json = registry_platform_crypto::parse_json_strict(&bytes) + .map_err(|_| TaskGrantError::Unavailable)?; + let status: StatusResponse = + serde_json::from_value(json).map_err(|_| TaskGrantError::Unavailable)?; + if !status.active || status.grant.as_ref() != Some(binding) || !binding.is_current() { + return Err(TaskGrantError::Refused); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/registry-breg/src/task_grant/config.rs b/crates/registry-breg/src/task_grant/config.rs new file mode 100644 index 0000000000..34caca2de1 --- /dev/null +++ b/crates/registry-breg/src/task_grant/config.rs @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Operator-pinned Casework endpoints and BREG's own status credentials. +use super::*; +use registry_platform_config::SecretResolver; +use registry_platform_httputil::client::PrivateKeyJwtConfig; + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantStatusConfig { + pub authority: String, + pub source_issuer: String, + pub base_url: String, + pub token_endpoint: String, + pub client_id: String, + pub private_key_ref: String, + pub casework_resource: String, + pub ca_bundle_ref: Option, +} + +pub struct TaskGrantStatusRegistry { + clients: BTreeMap<(String, String), TaskGrantStatusClient>, +} + +impl TaskGrantStatusRegistry { + pub fn contains(&self, authority: &str, source_issuer: &str) -> bool { + self.clients + .contains_key(&(authority.to_owned(), source_issuer.to_owned())) + } + + pub fn activate( + configs: &[TaskGrantStatusConfig], + resource: &str, + secrets: &SecretResolver, + ) -> Result { + if configs.len() > 32 { + return Err(TaskGrantError::Configuration); + } + let mut clients = BTreeMap::new(); + for config in configs { + let secret = secrets + .resolve(&config.private_key_ref) + .map_err(|_| TaskGrantError::Configuration)?; + let key = registry_platform_crypto::parse_json_strict(secret.expose_secret()) + .map_err(|_| TaskGrantError::Configuration)?; + let key = serde_json::from_value(key).map_err(|_| TaskGrantError::Configuration)?; + let ca = config + .ca_bundle_ref + .as_ref() + .map(|reference| secrets.resolve(reference)) + .transpose() + .map_err(|_| TaskGrantError::Configuration)?; + let mut token = PrivateKeyJwtConfig::new( + config + .token_endpoint + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + &config.client_id, + key, + ) + .with_resource(&config.casework_resource) + .with_scopes(["casework:grants:status"]); + if let Some(ca) = &ca { + token = token.with_trusted_root_certificates(ca.expose_secret().to_vec()); + } + let client = TaskGrantStatusClient::new( + config.authority.clone(), + config.source_issuer.clone(), + resource.to_owned(), + config + .base_url + .parse() + .map_err(|_| TaskGrantError::Configuration)?, + Arc::new(PrivateKeyJwt::new(token).map_err(|_| TaskGrantError::Configuration)?), + ca.as_ref().map(|value| value.expose_secret()), + )?; + if clients + .insert( + (config.authority.clone(), config.source_issuer.clone()), + client, + ) + .is_some() + { + return Err(TaskGrantError::Configuration); + } + } + Ok(Self { clients }) + } +} + +impl TaskGrantStatusChecker for TaskGrantStatusRegistry { + fn check<'a>( + &'a self, + binding: &'a TaskGrantBinding, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let client = self + .clients + .get(&(binding.authority.clone(), binding.source_issuer.clone())) + .ok_or(TaskGrantError::Refused)?; + client.check(binding).await + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_platform_config::SecretProvider; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn configured_status_clients_pin_authority_and_refuse_unsafe_or_duplicate_bindings() { + let root = tempfile::tempdir().unwrap(); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + let path = root.path().join("status-key"); + let mut private = serde_json::to_value(&key).unwrap(); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::fs::write(&path, serde_json::to_vec(&private).unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let secrets = SecretResolver::new([SecretProvider::File], root.path()).unwrap(); + let config = TaskGrantStatusConfig { + authority: "https://casework.test/tasks".into(), + source_issuer: "https://casework.test".into(), + base_url: "https://casework.test".into(), + token_endpoint: "https://identity.test/oauth2/token".into(), + client_id: "breg-status".into(), + private_key_ref: "secret:file/status-key".into(), + casework_resource: "urn:casework:test".into(), + ca_bundle_ref: None, + }; + let status = TaskGrantStatusRegistry::activate( + std::slice::from_ref(&config), + "urn:breg:test", + &secrets, + ) + .unwrap(); + assert!(status.contains(&config.authority, &config.source_issuer)); + assert!(!status.contains(&config.authority, "https://other.test")); + assert!(TaskGrantStatusRegistry::activate( + &[config.clone(), config.clone()], + "urn:breg:test", + &secrets + ) + .is_err()); + for url in [ + "http://casework.test", + "https://user@casework.test", + "https://casework.test/#fragment", + ] { + let mut invalid = config.clone(); + invalid.base_url = url.into(); + assert!( + TaskGrantStatusRegistry::activate(&[invalid], "urn:breg:test", &secrets).is_err(), + "{url}" + ); + } + let mut invalid = config.clone(); + invalid.casework_resource = "not-an-absolute-resource".into(); + assert!(TaskGrantStatusRegistry::activate(&[invalid], "urn:breg:test", &secrets).is_err()); + let mut raw = serde_json::to_value(config).unwrap(); + raw["scopes"] = serde_json::json!(["admin"]); + assert!(serde_json::from_value::(raw).is_err()); + } +} diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs new file mode 100644 index 0000000000..f39c7ac4d0 --- /dev/null +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -0,0 +1,187 @@ +use super::*; +use axum::{ + extract::State, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, +}; + +#[derive(Clone)] +struct StateData { + response: Arc>, + status_calls: Arc, + token_calls: Arc, +} +async fn token(State(state): State) -> Json { + state.token_calls.fetch_add(1, Ordering::SeqCst); + Json( + serde_json::json!({"access_token":"synthetic-status-service","token_type":"Bearer","expires_in":300,"scope":"casework:grants:status"}), + ) +} +async fn status(State(state): State, headers: HeaderMap) -> Json { + assert_eq!( + headers.get("authorization").unwrap(), + "Bearer synthetic-status-service" + ); + state.status_calls.fetch_add(1, Ordering::SeqCst); + Json(state.response.lock().unwrap().clone()) +} +fn binding() -> TaskGrantBinding { + serde_json::from_value(serde_json::json!({"grantId":Uuid::new_v4().to_string(),"authority":"casework","sourceIssuer":"https://casework.test","principal":"agent","client":"agent-client","resource":"urn:breg:test","purpose":"review","bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get","patch"]}]},"subjects":{"person_reference":"synthetic-person","active":true},"expiresAt":chrono::Utc::now().timestamp()+900})).unwrap() +} + +#[tokio::test] +async fn each_mutating_attempt_reads_fresh_status_and_compares_every_immutable_bound() { + let binding = binding(); + let response = Arc::new(Mutex::new( + serde_json::json!({"active":true,"grant":binding}), + )); + let state = StateData { + response: response.clone(), + status_calls: Arc::new(AtomicUsize::new(0)), + token_calls: Arc::new(AtomicUsize::new(0)), + }; + let app = Router::new() + .route("/token", post(token)) + .route("/v1/task-grants/{id}/status", get(status)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + let config = registry_platform_httputil::client::PrivateKeyJwtConfig::new( + format!("http://{address}/token").parse().unwrap(), + "breg-status", + key, + ) + .with_resource("urn:casework:test") + .with_scopes(["casework:grants:status"]); + let token = Arc::new(PrivateKeyJwt::new(config).unwrap()); + let client = TaskGrantStatusClient::new( + "casework".into(), + "https://casework.test".into(), + "urn:breg:test".into(), + format!("http://{address}").parse().unwrap(), + token, + None, + ) + .unwrap(); + client.check(&binding).await.unwrap(); + *response.lock().unwrap() = serde_json::json!({"active":false}); + assert_eq!(client.check(&binding).await, Err(TaskGrantError::Refused)); + for field in [ + "grantId", + "authority", + "sourceIssuer", + "principal", + "client", + "resource", + "purpose", + "bounds", + "subjects", + "expiresAt", + ] { + let mut changed = serde_json::to_value(&binding).unwrap(); + changed[field] = match field { + "grantId" => serde_json::json!(Uuid::new_v4().to_string()), + "bounds" => { + serde_json::json!({"type":"breg","permissions":[{"collection":"people","operations":["get"]}]}) + } + "subjects" => serde_json::json!({"person_reference":"other-person","active":true}), + "expiresAt" => serde_json::json!(binding.expires_at + 1), + _ => serde_json::json!("different"), + }; + *response.lock().unwrap() = serde_json::json!({"active":true,"grant":changed}); + assert_eq!( + client.check(&binding).await, + Err(TaskGrantError::Refused), + "{field}" + ); + } + *response.lock().unwrap() = serde_json::json!({"active":true,"grant":binding}); + client.check(&binding).await.unwrap(); + assert_eq!(state.status_calls.load(Ordering::SeqCst), 13); + assert_eq!( + state.token_calls.load(Ordering::SeqCst), + 1, + "only the service credential can be cached" + ); + let mut expired = binding.clone(); + expired.expires_at = 0; + assert_eq!(client.check(&expired).await, Err(TaskGrantError::Refused)); + assert_eq!( + state.status_calls.load(Ordering::SeqCst), + 13, + "expired authority is refused before I/O" + ); + server.abort(); +} + +#[test] +fn binding_debug_and_scalar_validation_preserve_privacy_and_exact_subjects() { + let mut grant = binding(); + assert_eq!(format!("{grant:?}"), "TaskGrantBinding()"); + assert!(grant.validate().is_ok()); + for value in [ + Value::Null, + serde_json::json!(["person"]), + serde_json::json!({"id":"person"}), + serde_json::json!(1.5), + ] { + grant.subjects.insert("person_reference".into(), value); + assert_eq!(grant.validate(), Err(TaskGrantError::Refused)); + } +} + +#[cfg(feature = "postgres-test")] +#[test] +fn write_idempotency_separates_grants_without_changing_read_authority() { + use crate::{ + idempotency::{canonical_claim_context, resolve_binding, IdempotencyBinding}, + model::HttpMethod, + postgres::ClaimContext, + }; + let profile = registry_platform_audit::AuditProfile::unkeyed_dev_only(); + let base = ClaimContext::kernel_for_test( + "agent".into(), + "task".into(), + Some("review".into()), + "subject".into(), + ) + .unwrap(); + let first = base.clone().with_task_grant(binding()).unwrap(); + let second = base.with_task_grant(binding()).unwrap(); + assert_eq!( + canonical_claim_context(&profile, &first, "package").unwrap(), + canonical_claim_context(&profile, &second, "package").unwrap() + ); + let fields = std::collections::BTreeSet::from(["status".into()]); + let resolve = |context| { + resolve_binding( + &profile, + &IdempotencyBinding { + key: "same-key", + context, + method: HttpMethod::Post, + route: "/requests", + target_record: None, + package_revision: "package", + response_fields: &fields, + canonical_request_digest: [1; 32], + }, + ) + .unwrap() + }; + let first = resolve(&first); + let second = resolve(&second); + assert_eq!(first.key_reference, second.key_reference); + assert_ne!(first.binding_reference, second.binding_reference); +} diff --git a/crates/registry-breg/src/tests/artifacts_response_schema_tests.rs b/crates/registry-breg/src/tests/artifacts_response_schema_tests.rs index 74e8e19a7f..d7afe04833 100644 --- a/crates/registry-breg/src/tests/artifacts_response_schema_tests.rs +++ b/crates/registry-breg/src/tests/artifacts_response_schema_tests.rs @@ -917,7 +917,7 @@ fn compiled_registry() -> CompiledRegistry { } }], "accessProfiles":[{ - "id":"request-reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"request-reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","approve_request","reject_request","request_revision"], "readableFields":["placement","proposed-site"], @@ -932,7 +932,7 @@ fn compiled_registry() -> CompiledRegistry { "rowBoundaries": [] }] },{ - "id":"request-submitter","principalClaim":"principal","grants":[{ + "id":"request-submitter","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","create","patch","submit_request"], "readableFields":["placement","proposed-site"], @@ -940,7 +940,7 @@ fn compiled_registry() -> CompiledRegistry { "rowBoundaries": [] }] },{ - "id":"request-applier","principalClaim":"principal","grants":[{ + "id":"request-applier","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["placement"], @@ -948,7 +948,7 @@ fn compiled_registry() -> CompiledRegistry { "rowBoundaries": [] }] },{ - "id":"placement-viewer","principalClaim":"principal","grants":[{ + "id":"placement-viewer","principalClaim":"principal","permissions":[{ "entity":"placement", "operations":["get"], "readableFields":["site"], @@ -1026,7 +1026,7 @@ fn compiled_action_registry() -> CompiledRegistry { "principalClaim":"private_claim_name", "requiredScopes":["registry:contact:register"], "requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"register-household-contact", "operations":["invoke"], "targets":[ @@ -1041,7 +1041,7 @@ fn compiled_action_registry() -> CompiledRegistry { "principalClaim":"other_private_claim", "requiredScopes":["registry:contact:audit"], "requiredPurposes":["contact-audit"], - "grants":[{ + "permissions":[{ "action":"register-household-contact", "operations":["invoke"], "targets":[ @@ -1097,7 +1097,7 @@ fn compiled_asset_result_action_registry() -> CompiledRegistry { "default":true, "principalClaim":"principal", "requiredScopes":["registry:asset:register"], - "grants":[{ + "permissions":[{ "action":"register-asset", "operations":["invoke"], "targets":[ diff --git a/crates/registry-breg/tests/access_configuration.rs b/crates/registry-breg/tests/access_configuration.rs index 3d0e781709..c3db6a85e1 100644 --- a/crates/registry-breg/tests/access_configuration.rs +++ b/crates/registry-breg/tests/access_configuration.rs @@ -13,7 +13,7 @@ fn source() -> Value { "accessRequirements":{"requiredScopes":["entry:read"],"allowedPurposes":["administration"], "rowBoundaries":[{"field":"district","claim":"districts","operator":"in"}]}}], "accessProfiles":[{"id":"reader","principalClaim":"registry_principal","requiredScopes":["entry:read"], - "requiredPurposes":["administration"],"grants":[{"entity":"entry","operations":["get","list"], + "requiredPurposes":["administration"],"permissions":[{"entity":"entry","operations":["get","list"], "readableFields":["code","district"],"filterableFields":["district"], "rowBoundaries":[{"field":"district","claim":"districts","operator":"in"}]}]}] }) @@ -27,6 +27,26 @@ fn compile(value: &Value) -> Result Value { json!({"id":"auditor","principalClaim":"registry_principal","requiredScopes":["entry:read"], - "requiredPurposes":["administration"],"grants":[{"entity":"entry","operations":["get","list"], + "requiredPurposes":["administration"],"permissions":[{"entity":"entry","operations":["get","list"], "readableFields":["code","district"],"filterableFields":["district"], "rowBoundaries":[{"field":"district","claim":"districts","operator":"in"}]}]}) } @@ -641,7 +662,7 @@ fn anonymous_source() -> Value { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"public"}, {"id":"note","type":"string","maxLength":32,"classification":"internal"}]}], "accessProfiles":[{"id":"public-map","default":true,"anonymous":true, - "grants":[{"entity":"place","operations":["list"],"readableFields":["code"], "rowBoundaries": []}]}] + "permissions":[{"entity":"place","operations":["list"],"readableFields":["code"], "rowBoundaries": []}]}] }) } @@ -667,7 +688,7 @@ fn anonymous_processing_refusals_name_the_entity_or_field_that_is_not_public() { compile(&public_entity).expect("a public entity with public readable fields compiles"); let mut hidden_field = public_entity; - hidden_field["accessProfiles"][0]["grants"][0]["readableFields"] = json!(["code", "note"]); + hidden_field["accessProfiles"][0]["permissions"][0]["readableFields"] = json!(["code", "note"]); let failure = compile(&hidden_field).unwrap_err(); let diagnostic = diagnostic_for( &failure, @@ -684,7 +705,7 @@ fn anonymous_processing_refusals_name_the_entity_or_field_that_is_not_public() { #[test] fn write_grants_without_writable_fields_and_anonymous_collections_are_reported() { let mut value = source(); - value["accessProfiles"][0]["grants"][0]["operations"] = + value["accessProfiles"][0]["permissions"][0]["operations"] = json!(["get", "list", "create", "patch"]); let compiled = compile(&value).unwrap(); let finding = compiled @@ -700,7 +721,7 @@ fn write_grants_without_writable_fields_and_anonymous_collections_are_reported() finding.message.contains("`create`") && finding.message.contains("`patch`"), "{finding:?}" ); - value["accessProfiles"][0]["grants"][0]["writableFields"] = json!(["code"]); + value["accessProfiles"][0]["permissions"][0]["writableFields"] = json!(["code"]); assert!(!compile(&value) .unwrap() .findings() @@ -727,7 +748,7 @@ fn write_grants_without_writable_fields_and_anonymous_collections_are_reported() .any(|d| d.code == "access.profile.unrestricted_collection"), "a public entity keeps the authenticated-only collection finding out of the report" ); - public["accessProfiles"][0]["grants"][0]["operations"] = json!(["get"]); + public["accessProfiles"][0]["permissions"][0]["operations"] = json!(["get"]); assert!(!compile(&public) .unwrap() .findings() @@ -774,7 +795,7 @@ fn sole_profile_is_implicit_default_and_workflow_routes_may_require_explicit_sel { profile.default = true; // Reach stage-specific route validation rather than the shared GET route. - for grant in &mut profile.grants { + for grant in &mut profile.permissions { grant .operations .remove(®istry_breg::contract::Operation::Get); diff --git a/crates/registry-breg/tests/access_explanation.rs b/crates/registry-breg/tests/access_explanation.rs index e1265fb3d3..c6985c8d15 100644 --- a/crates/registry-breg/tests/access_explanation.rs +++ b/crates/registry-breg/tests/access_explanation.rs @@ -10,7 +10,7 @@ fn source() -> Value { "entities":[{"id":"entry","primaryDataset":"test-dataset","route":"entries","mutationMode":"mutable","classification":"internal", "fields":[{"id":"district","type":"string","maxLength":32,"classification":"internal"}]}], "accessProfiles":[{"id":"clerk","principalClaim":"registry_principal","requiredScopes":["entry:edit"], - "grants":[{"entity":"entry","operations":["get","patch"],"readableFields":["district"],"writableFields":["district"], + "permissions":[{"entity":"entry","operations":["get","patch"],"readableFields":["district"],"writableFields":["district"], "rowBoundaries":[{"field":"district","claim":"districts","operator":"in"}]}]}] }) } @@ -44,7 +44,7 @@ fn membership_source() -> Value { ]}), ]); source["accessProfiles"][0]["principalClaim"] = json!("sub"); - let grant = &mut source["accessProfiles"][0]["grants"][0]; + let grant = &mut source["accessProfiles"][0]["permissions"][0]; grant["operations"] = json!(["get", "list"]); grant["writableFields"] = json!([]); grant["rowBoundaries"] = json!([]); @@ -82,7 +82,7 @@ fn access_explanation_connects_row_reach_to_typed_claim_requirements() { .contains("not evaluated")); let mut broad = source(); - broad["accessProfiles"][0]["grants"][0]["rowBoundaries"] = json!([]); + broad["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = json!([]); let registry = compile(&broad); assert!(registry .findings() @@ -144,7 +144,7 @@ fn membership_row_reach_is_explicit_and_uses_the_selected_principal() { ), ] { let mut source = membership_source(); - source["accessProfiles"][0]["grants"][0]["rowBoundaries"] = boundaries; + source["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = boundaries; let registry = compile(&source); let explanation = registry_breg::access::explain_access(®istry); let reach = explanation @@ -195,16 +195,16 @@ fn membership_changes_report_authority_narrowing_and_widening() { .unwrap() .push(json!({"id":"approved", "type":"boolean", "classification":"internal"})); let mut additional = - source["accessProfiles"][0]["grants"][0]["membershipBoundaries"][0].clone(); + source["accessProfiles"][0]["permissions"][0]["membershipBoundaries"][0].clone(); additional["activeField"] = json!("approved"); - let mut boundaries = source["accessProfiles"][0]["grants"][0]["membershipBoundaries"] + let mut boundaries = source["accessProfiles"][0]["permissions"][0]["membershipBoundaries"] .as_array() .unwrap() .clone(); boundaries.push(additional); let registries = (0..=2) .map(|count| { - source["accessProfiles"][0]["grants"][0]["membershipBoundaries"] = + source["accessProfiles"][0]["permissions"][0]["membershipBoundaries"] = json!(&boundaries[..count]); compile(&source) }) @@ -230,7 +230,7 @@ fn membership_changes_report_authority_narrowing_and_widening() { #[test] fn synthetic_own_record_preview_reuses_principal_and_refuses_identity_override() { let mut source = source(); - source["accessProfiles"][0]["grants"][0]["rowBoundaries"] = + source["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = json!([{"field":"district","claim":"registry_principal","operator":"equals"}]); let registry = compile(&source); let mut scenario = json!({ diff --git a/crates/registry-breg/tests/action_evidence_compiler.rs b/crates/registry-breg/tests/action_evidence_compiler.rs index daa6fcaa87..7c70a1579d 100644 --- a/crates/registry-breg/tests/action_evidence_compiler.rs +++ b/crates/registry-breg/tests/action_evidence_compiler.rs @@ -22,7 +22,7 @@ fn project() -> Value { {"id":"friend","target":{"entity":"person"},"operation":"create","fields":["name","friend"]}, {"id":"existing","target":{"fromField":"person"},"operation":"patch","fields":["name","friend"]} ]}}], - "accessProfiles":[{"id":"registrar","default":true,"principalClaim":"principal","grants":[{"action":"register-person","operations":["invoke"],"targets":[{"entity":"person","rowBoundaries":[]}],"results":["person","friend","existing"]}]}] + "accessProfiles":[{"id":"registrar","default":true,"principalClaim":"principal","permissions":[{"action":"register-person","operations":["invoke"],"targets":[{"entity":"person","rowBoundaries":[]}],"results":["person","friend","existing"]}]}] }) } diff --git a/crates/registry-breg/tests/change_request_diagnostics.rs b/crates/registry-breg/tests/change_request_diagnostics.rs index 6f29778f8d..4b67eb9e90 100644 --- a/crates/registry-breg/tests/change_request_diagnostics.rs +++ b/crates/registry-breg/tests/change_request_diagnostics.rs @@ -51,12 +51,12 @@ fn change_control_direct_write_grant_identifies_entity_and_profile() { "review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"asset-operator","principalClaim":"principal","grants":[{ + "id":"asset-operator","principalClaim":"principal","permissions":[{ "entity":"asset","operations":["get","patch"],"readableFields":["label"],"writableFields":["label"], "rowBoundaries": [] }] },{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"asset-placement-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["asset","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"asset","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"asset", "rowBoundaries": []}], @@ -92,7 +92,7 @@ fn change_control_required_for_empty_identifies_entity() { "review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"asset-placement-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["asset","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"asset","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"asset", "rowBoundaries": []}], @@ -128,7 +128,7 @@ fn change_request_review_stage_approvals_invalid_identifies_entity_and_stage() { "review":{"stages":[{"id":"review","approvals":0}]}} }], "accessProfiles":[{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"asset-placement-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["asset","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"asset","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"asset", "rowBoundaries": []}], @@ -166,7 +166,7 @@ fn change_request_effect_paths_use_index_when_id_missing_and_id_when_present() { ],"review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"asset-placement-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["asset","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"asset","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"asset", "rowBoundaries": []}], @@ -206,7 +206,7 @@ fn change_request_submit_operation_missing_identifies_entity() { "review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"asset-placement-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"asset-placement-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"asset-placement-request","operations":["get"],"readableFields":["asset","label"], "rowBoundaries": [] }] diff --git a/crates/registry-breg/tests/compiler_contract.rs b/crates/registry-breg/tests/compiler_contract.rs index f99d2e4543..7a0e0f6be8 100644 --- a/crates/registry-breg/tests/compiler_contract.rs +++ b/crates/registry-breg/tests/compiler_contract.rs @@ -11,7 +11,7 @@ use registry_breg::compiler::{ }; use registry_breg::contract::{ parse_module_json, parse_module_yaml, parse_project_json, parse_project_yaml, - AccessGrantSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, + AccessPermissionSource, BoundaryOperator, Classification, ComparisonOperator, ConstraintSource, FieldTypeSource, ModuleAssetSource, Operation, PackageIdentitySource, ProjectAccessProfileSource, ReferenceDelete, RegistryModule, RowBoundarySource, UniqueWhenPredicate, @@ -88,7 +88,7 @@ fn multi_dataset_project() -> registry_breg::contract::RegistryProject { {"id":"resident","type":"reference","target":"person","classification":"internal"} ]} ], - "accessProfiles":[{"id":"reader","default":true,"principalClaim":"sub","requiredScopes":["registry.read"],"grants":[ + "accessProfiles":[{"id":"reader","default":true,"principalClaim":"sub","requiredScopes":["registry.read"],"permissions":[ {"entity":"person","operations":["get","list"],"readableFields":["name"], "rowBoundaries": []}, {"entity":"residence","operations":["get","list"],"readableFields":["place","resident"], "rowBoundaries": []} ]}] @@ -219,8 +219,8 @@ fn manifest_projection_excludes_every_protected_resource_from_public_bytes() { ]} ], "accessProfiles":[ - {"id":"public-reader","anonymous":true,"grants":[{"entity":"public-record","operations":["get"],"readableFields":["label","protected-link"], "rowBoundaries": []}]}, - {"id":"protected-reader","principalClaim":"sub","requiredScopes":["protected.read"],"grants":[{"entity":"protected-record","operations":["get"],"readableFields":["protected-title"], "rowBoundaries": []}]} + {"id":"public-reader","anonymous":true,"permissions":[{"entity":"public-record","operations":["get"],"readableFields":["label","protected-link"], "rowBoundaries": []}]}, + {"id":"protected-reader","principalClaim":"sub","requiredScopes":["protected.read"],"permissions":[{"entity":"protected-record","operations":["get"],"readableFields":["protected-title"], "rowBoundaries": []}]} ] }"#, ) @@ -578,17 +578,17 @@ fn change_request_correction_project( }} }}{extra_entity}], "accessProfiles":[{{ - "id":"placement-reader","principalClaim":"principal","grants":[{{ + "id":"placement-reader","principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"placement","operations":["get","list"],"readableFields":["site","label"], "requestPresence":[{{"rowBoundaries": [], "requestType":"placement-correction-request"}}] }}] }},{{ - "id":"request-reviewer","default":true,"principalClaim":"principal","requiredScopes":{reviewer_scopes},"grants":[{{ + "id":"request-reviewer","default":true,"principalClaim":"principal","requiredScopes":{reviewer_scopes},"permissions":[{{ "rowBoundaries": [], "entity":"placement-correction-request","operations":["get","list","submit_request","approve_request","reject_request","request_revision"],"readableFields":["placement","proposed-site","reason"], "reviewStages":[{{"stage":"review","targets":[{{"entity":"placement","readableFields":["site","label"],"rowBoundaries":{review_boundaries}}}]}}] }}] }},{{ - "id":"request-applier","principalClaim":"principal","grants":[{{ + "id":"request-applier","principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"placement-correction-request","operations":["get","apply_request"],"readableFields":["placement"], "applyTargets":[{{"entity":"placement","rowBoundaries":{apply_boundaries}}}] }}] @@ -625,7 +625,7 @@ fn anonymous_request_presence_processes_only_public_existence_and_linkage() { reader.insert("anonymous".to_owned(), json!(true)); project["accessProfiles"].as_array_mut().unwrap().push(json!({ "id":"request-public", "anonymous":true, - "grants":[{"entity":"placement-correction-request","operations":["get","list"],"readableFields":["placement"], "rowBoundaries": []}] + "permissions":[{"entity":"placement-correction-request","operations":["get","list"],"readableFields":["placement"], "rowBoundaries": []}] })); let registry = compile_json(&serde_json::to_vec(&project).unwrap()) .expect("public existence and linkage may be disclosed without private intake detail"); @@ -672,7 +672,7 @@ fn anonymous_request_presence_processes_only_public_existence_and_linkage() { })); } - project["accessProfiles"][0]["grants"][0]["requestPresence"][0]["rowBoundaries"] = + project["accessProfiles"][0]["permissions"][0]["requestPresence"][0]["rowBoundaries"] = json!([{"field":"placement","claim":"placement","operator":"equals"}]); let failure = compile_json(&serde_json::to_vec(&project).unwrap()) .expect_err("an anonymous presence grant cannot acquire verified claim authority"); @@ -716,19 +716,19 @@ fn change_request_correction_compiles_to_immutable_plan_and_scoped_grants() { } }], "accessProfiles":[{ - "id":"placement-reader","principalClaim":"principal","grants":[{ + "id":"placement-reader","principalClaim":"principal","permissions":[{ "entity":"placement","operations":["get","list"],"readableFields":["site","label"], "requestPresence":[{"requestType":"placement-correction-request", "rowBoundaries": []}], "rowBoundaries": [] }] },{ - "id":"request-reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"request-reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["get","list","submit_request","approve_request","reject_request","request_revision"],"readableFields":["placement","proposed-site","reason"], "reviewStages":[{"stage":"review","targets":[{"entity":"placement","readableFields":["site","label"],"rowBoundaries":[]}]}], "rowBoundaries": [] }] },{ - "id":"request-applier","principalClaim":"principal","grants":[{ + "id":"request-applier","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["get","apply_request"],"readableFields":["placement"], "applyTargets":[{"entity":"placement","rowBoundaries":[]}], "rowBoundaries": [] @@ -775,16 +775,16 @@ fn change_request_correction_compiles_to_immutable_plan_and_scoped_grants() { mutation, CompiledChangeRequestMutation::Clear { field } if field == "label" ))); - assert!(request.review_grants.iter().any(|grant| { + assert!(request.review_permissions.iter().any(|grant| { grant.profile_id == "request-reviewer" && grant.stage == "review" && grant.target_entity_id == "placement" && grant.readable_fields == BTreeSet::from(["site".to_owned(), "label".to_owned()]) })); - assert!(request.apply_grants.iter().any(|grant| { + assert!(request.apply_permissions.iter().any(|grant| { grant.profile_id == "request-applier" && grant.target_entity_id == "placement" })); - assert!(request.presence_grants.iter().any(|grant| { + assert!(request.presence_permissions.iter().any(|grant| { grant.profile_id == "placement-reader" && grant.target_entity_id == "placement" })); } @@ -898,30 +898,30 @@ fn change_request_openapi_exposes_finite_action_contract_and_request_metadata() } }], "accessProfiles":[{ - "id":"placement-reader","principalClaim":"principal","grants":[{ + "id":"placement-reader","principalClaim":"principal","permissions":[{ "entity":"placement","operations":["get","list"],"readableFields":["site","label"], "requestPresence":[{"requestType":"placement-correction-request", "rowBoundaries": []}], "rowBoundaries": [] }] },{ - "id":"submitter","default":true,"principalClaim":"principal","grants":[{ + "id":"submitter","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["create","patch","submit_request","revise_request","cancel_request"],"readableFields":["placement","proposed-site","reason"],"writableFields":["placement","proposed-site","reason"], "rowBoundaries": [] }] },{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["get","list","approve_request","reject_request","request_revision"],"readableFields":["placement","proposed-site","reason"], "reviewStages":[{"stage":"review","targets":[{"entity":"placement","readableFields":["site","label"],"rowBoundaries":[]}]}], "rowBoundaries": [] }] },{ - "id":"supervisor","principalClaim":"principal","grants":[{ + "id":"supervisor","principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["approve_request","reject_request","request_revision"],"readableFields":["placement","proposed-site","reason"], "reviewStages":[{"stage":"final-approval","targets":[{"entity":"placement","readableFields":["site","label"],"rowBoundaries":[]}]}], "rowBoundaries": [] }] },{ - "id":"applier","default":true,"principalClaim":"principal","grants":[{ + "id":"applier","default":true,"principalClaim":"principal","permissions":[{ "entity":"placement-correction-request","operations":["apply_request"],"readableFields":["placement"], "applyTargets":[{"entity":"placement","rowBoundaries":[]}], "rowBoundaries": [] @@ -1196,11 +1196,11 @@ fn correction_with_target_access_requirements() -> serde_json::Value { profile["requiredScopes"] = serde_json::json!(["target:manage"]); profile["requiredPurposes"] = serde_json::json!(["target-management"]); } - source["accessProfiles"][0]["grants"][0]["rowBoundaries"] = + source["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = serde_json::json!([boundary.clone()]); - source["accessProfiles"][1]["grants"][0]["reviewStages"][0]["targets"][0]["rowBoundaries"] = - serde_json::json!([boundary.clone()]); - source["accessProfiles"][2]["grants"][0]["applyTargets"][0]["rowBoundaries"] = + source["accessProfiles"][1]["permissions"][0]["reviewStages"][0]["targets"][0] + ["rowBoundaries"] = serde_json::json!([boundary.clone()]); + source["accessProfiles"][2]["permissions"][0]["applyTargets"][0]["rowBoundaries"] = serde_json::json!([boundary]); source } @@ -1213,10 +1213,14 @@ fn change_request_review_and_apply_cannot_omit_target_access_requirements() { for (profile_index, target_path, surface) in [ ( 1, - "/grants/0/reviewStages/0/targets/0/rowBoundaries", + "/permissions/0/reviewStages/0/targets/0/rowBoundaries", "reviewStages", ), - (2, "/grants/0/applyTargets/0/rowBoundaries", "applyTargets"), + ( + 2, + "/permissions/0/applyTargets/0/rowBoundaries", + "applyTargets", + ), ] { for (path, replacement, code) in [ ( @@ -1319,10 +1323,10 @@ fn change_request_presence_cannot_omit_request_access_requirements() { { profile["requiredScopes"] = serde_json::json!(["target:manage", "request:read"]); if index > 0 { - profile["grants"][0]["rowBoundaries"] = serde_json::json!([boundary.clone()]); + profile["permissions"][0]["rowBoundaries"] = serde_json::json!([boundary.clone()]); } } - source["accessProfiles"][0]["grants"][0]["requestPresence"][0]["rowBoundaries"] = + source["accessProfiles"][0]["permissions"][0]["requestPresence"][0]["rowBoundaries"] = serde_json::json!([boundary]); compile_json(&serde_json::to_vec(&source).unwrap()) .expect("presence explicitly satisfies request requirements"); @@ -1338,12 +1342,12 @@ fn change_request_presence_cannot_omit_request_access_requirements() { "access.requirements.purpose_widened", ), ( - "/grants/0/requestPresence/0/rowBoundaries", + "/permissions/0/requestPresence/0/rowBoundaries", serde_json::json!([]), "access.requirements.row_boundary_missing", ), ( - "/grants/0/requestPresence/0/rowBoundaries", + "/permissions/0/requestPresence/0/rowBoundaries", serde_json::json!([{"field":"placement","claim":"different_placements","operator":"in"}]), "access.requirements.row_boundary_missing", ), @@ -1397,7 +1401,7 @@ fn change_request_multi_record_create_and_patch_orders_reserved_references() { } }], "accessProfiles":[{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{ "entity":"registration-request","operations":["get","list","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["household","name"], "reviewStages":[{"stage":"review","targets":[ {"entity":"person","readableFields":["display-name"], "rowBoundaries": []}, @@ -1472,9 +1476,9 @@ fn change_request_compile_refuses_direct_write_bypass_and_incomplete_grants() { "review":{{"stages":[{{"id":"review","approvals":1}}]}}}} }}], "accessProfiles":[{{ - "id":"target-writer","principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"placement","operations":{grant_ops},"readableFields":["site"],"writableFields":["site"]}}] + "id":"target-writer","principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"placement","operations":{grant_ops},"readableFields":["site"],"writableFields":["site"]}}] }},{{ - "id":"reviewer","default":true,"principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"correction-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["placement","site"], + "id":"reviewer","default":true,"principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"correction-request","operations":["get","submit_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["placement","site"], "reviewStages":[{{"stage":"review","targets":[{{"rowBoundaries": [], "entity":"placement","readableFields":{review_fields}}}]}}], "applyTargets":{apply_targets} }}] @@ -1536,7 +1540,7 @@ fn change_request_compile_refuses_ambiguous_references_cycles_overlaps_and_null_ "changeRequest":{{"effects":[{effect}],"review":{{"stages":[{{"id":"review","approvals":1}}]}}}} }}], "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","value","optional-value"], "reviewStages":[{{"stage":"review","targets":[{{"rowBoundaries": [], "entity":"record","readableFields":["label","parent"]}}]}}], "applyTargets":[{{"rowBoundaries": [], "entity":"record"}}] @@ -1690,7 +1694,7 @@ fn change_request_compile_refuses_uncontrolled_targets_tombstone_requests_and_pl "review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"target", "rowBoundaries": []}], @@ -1723,7 +1727,7 @@ fn change_request_compile_refuses_uncontrolled_targets_tombstone_requests_and_pl "review":{"stages":[{"id":"review","approvals":1}]}} }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"request","operations":["get","tombstone","submit_request","approve_request","apply_request"],"readableFields":["target","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"target", "rowBoundaries": []}], @@ -1773,7 +1777,7 @@ fn change_request_compile_refuses_uncontrolled_targets_tombstone_requests_and_pl "fields":[{}], "changeRequest":{{"effects":[{}],"review":{{"stages":[{{"id":"review","approvals":1}}]}}}} }}], - "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target"], + "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target"], "reviewStages":[{{"stage":"review","targets":[{{"rowBoundaries": [], "entity":"target","readableFields":[{}]}}]}}], "applyTargets":[{{"rowBoundaries": [], "entity":"target"}}]}}]}}] }}"#, @@ -1799,7 +1803,7 @@ fn change_request_compile_refuses_invalid_lifecycle_surface_bounds_and_controls( "registry":{"id":"misplaced-lifecycle","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://authoring.example.test"}, "entities":[{"id":"record","primaryDataset":"test-dataset","route":"records","mutationMode":"create_only", "fields":[{"id":"label","type":"string","maxLength":32,"classification":"internal"}]}], - "accessProfiles":[{"id":"operator","principalClaim":"principal","grants":[{"entity":"record","operations":["get","submit_request"],"readableFields":["label"], "rowBoundaries": []}]}] + "accessProfiles":[{"id":"operator","principalClaim":"principal","permissions":[{"entity":"record","operations":["get","submit_request"],"readableFields":["label"], "rowBoundaries": []}]}] }"#, ) .expect_err("request lifecycle operations are available only on request entities"); @@ -1815,7 +1819,7 @@ fn change_request_compile_refuses_invalid_lifecycle_surface_bounds_and_controls( "registry":{"id":"unsupported-control","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://authoring.example.test"}, "entities":[{"id":"record","primaryDataset":"test-dataset","route":"records","mutationMode":"mutable","changeControl":{"requiredFor":["tombstone"]}, "fields":[{"id":"label","type":"string","maxLength":32,"classification":"internal"}]}], - "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[{"entity":"record","operations":["get"],"readableFields":["label"], "rowBoundaries": []}]}] + "accessProfiles":[{"id":"reader","principalClaim":"principal","permissions":[{"entity":"record","operations":["get"],"readableFields":["label"], "rowBoundaries": []}]}] }"#, ) .expect_err("change control is bounded to create and patch operations"); @@ -1841,7 +1845,7 @@ fn change_request_compile_refuses_invalid_lifecycle_surface_bounds_and_controls( "changeRequest":{"effects":[{"target":{"fromField":"target"},"operation":"patch","set":{"label":{"fromField":"label"}}}], "review":{"stages":[{"id":"review","approvals":1}]}} }], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{"entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{"entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"target", "rowBoundaries": []}], "rowBoundaries": []}]}] }"#, @@ -1877,7 +1881,7 @@ fn change_request_compile_refuses_invalid_lifecycle_surface_bounds_and_controls( "changeRequest":{"effects":[{"target":{"fromField":"inner"},"operation":"patch","set":{"label":{"fromField":"label"}}}], "review":{"stages":[{"id":"review","approvals":1}]}} }], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{ + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"inner-request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["label"], "rowBoundaries": []}]}], "applyTargets":[{"entity":"target", "rowBoundaries": []}], @@ -1915,7 +1919,7 @@ fn change_request_compile_refuses_invalid_lifecycle_surface_bounds_and_controls( "changeRequest":{{"effects":[{{"target":{{"fromField":"target"}},"operation":"patch","set":{{"label":{{"fromField":"label"}}}}}}], "review":{{"stages":[{stages}]}}}} }}], - "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], + "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"request","operations":["get","submit_request","approve_request","apply_request"],"readableFields":["target","label"], "reviewStages":[{{"stage":"stage-0","targets":[{{"rowBoundaries": [], "entity":"target","readableFields":["label"]}}]}}], "applyTargets":[{{"rowBoundaries": [], "entity":"target"}}]}}]}}] }}"# @@ -1967,7 +1971,7 @@ fn derived_fields_selectors_and_read_paths_compile_to_route_specific_inventories ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"sub","grants":[{ + "id":"operator","default":true,"principalClaim":"sub","permissions":[{ "entity":"household","operations":["get","lookup","list"], "readableFields":["household-code","child-count","single-headed"], "filterableFields":["child-count","single-headed"], @@ -2100,7 +2104,7 @@ fn canonical_id_row_boundary_targets_the_physical_record_id_column() { ] }], "accessProfiles":[{ - "id":"viewer","principalClaim":"sub","grants":[{ + "id":"viewer","principalClaim":"sub","permissions":[{ "entity":"household","operations":["get"], "readableFields":["household-code"], "rowBoundaries":[{"field":"id","claim":"household_id","operator":"equals"}] @@ -2210,7 +2214,7 @@ fn anonymous_access_cannot_process_selector_path_or_derived_private_fields() { "derived":[{{"id":"flags","sql":"sql/flags.sql","key":"id","fields":[{{"id":"risk-flag","type":"boolean","classification":"public"}}]}}], "selectorProfiles":[{{"id":"by-private-code","fields":["private-code"]}}] }}], - "accessProfiles":[{{"id":"anon","anonymous":true,"grants":[{{"rowBoundaries": [], "entity":"household","operations":["lookup"],{extra}}}]}}] + "accessProfiles":[{{"id":"anon","anonymous":true,"permissions":[{{"rowBoundaries": [], "entity":"household","operations":["lookup"],{extra}}}]}}] }}"# ) }; @@ -2286,7 +2290,7 @@ fn batch_route_requires_explicit_bounds_and_compiles_bounded_openapi() { "fields":[{{"id":"label","type":"string","maxLength":32,"required":true,"classification":"internal"}}] }}], "accessProfiles":[{{ - "id":"writer","principalClaim":"principal","grants":[{{ + "id":"writer","principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"record","operations":{operations}, "readableFields":["label"],"writableFields":["label"] }}] @@ -2574,7 +2578,7 @@ fn production_allows_missing_manifest_projection_and_emits_no_manifest_artifacts "accessProfiles":[{ "id":"reader", "principalClaim":"principal", - "grants":[{ + "permissions":[{ "entity":"record", "operations":["get"], "readableFields":["code"], @@ -2628,7 +2632,7 @@ fn project_access_profiles_use_the_entity_access_vocabulary() { "principalClaim":"sub", "requiredScopes":["records.read"], "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"case-file", "operations":["get","list"], "readableFields":["case-code","status"], @@ -2775,7 +2779,7 @@ fn anonymous_project_access_profiles_expand_without_authenticated_claims() { "id":"public-reader", "default":true, "anonymous":true, - "grants":[{ + "permissions":[{ "entity":"public-record", "operations":["get","list"], "readableFields":["code","name"], @@ -2821,7 +2825,7 @@ fn anonymous_project_access_profiles_cannot_require_authenticated_claims() { "id":"public-reader", "anonymous":true, {extra} - "grants":[{{"rowBoundaries": [], "entity":"public-record","operations":["get"],"readableFields":["code"]}}] + "permissions":[{{"rowBoundaries": [], "entity":"public-record","operations":["get"],"readableFields":["code"]}}] }}] }}"# ) @@ -2869,7 +2873,7 @@ fn project_access_profiles_reject_the_legacy_purpose_vocabulary() { "id":"operator", "principalClaim":"sub", "purposes":["case-management"], - "grants":[{"entity":"case-file","operations":["get"],"readableFields":["case-code"], "rowBoundaries": []}] + "permissions":[{"entity":"case-file","operations":["get"],"readableFields":["case-code"], "rowBoundaries": []}] }] }"#, ) @@ -2895,7 +2899,7 @@ fn project_access_grants_reject_the_legacy_action_vocabulary() { "id":"operator", "principalClaim":"sub", "requiredPurposes":["case-management"], - "grants":[{"entity":"case-file","actions":["get"],"readableFields":["case-code"], "rowBoundaries": []}] + "permissions":[{"entity":"case-file","actions":["get"],"readableFields":["case-code"], "rowBoundaries": []}] }] }"#, ) @@ -2905,7 +2909,7 @@ fn project_access_grants_reject_the_legacy_action_vocabulary() { assert_eq!(diagnostic.code, "source.shape.invalid"); assert_eq!( diagnostic.path, - "project.accessProfiles[0].grants[0].actions" + "project.accessProfiles[0].permissions[0].actions" ); } @@ -2927,7 +2931,7 @@ fn entity_access_grants_reject_action_target_and_result_fields() { "accessProfiles":[{{ "id":"operator", "principalClaim":"sub", - "grants":[{{ + "permissions":[{{ "rowBoundaries": [], "entity":"case-file", "operations":["get"], "readableFields":["case-code"]{extra} @@ -2938,8 +2942,8 @@ fn entity_access_grants_reject_action_target_and_result_fields() { let failure = compile_json(source.as_bytes()) .expect_err("entity grants cannot carry action-only fields"); assert!(failure.diagnostics().iter().any(|diagnostic| { - diagnostic.code == "access_profile.grant.action_fields_forbidden" - && diagnostic.path == "project.accessProfiles[].grants[]" + diagnostic.code == "access_profile.permission.action_fields_forbidden" + && diagnostic.path == "project.accessProfiles[].permissions[]" })); } } @@ -2963,7 +2967,7 @@ fn project_access_grants_reject_mixed_entity_and_action_targets() { "accessProfiles":[{ "id":"operator", "principalClaim":"sub", - "grants":[{ + "permissions":[{ "entity":"case-file", "action":"create-case-file", "operations":["invoke"], @@ -2975,8 +2979,8 @@ fn project_access_grants_reject_mixed_entity_and_action_targets() { ) .expect_err("grants cannot name both an entity and an action"); assert!(failure.diagnostics().iter().any(|diagnostic| { - diagnostic.code == "access_profile.grant.target_exclusive" - && diagnostic.path == "project.accessProfiles[].grants[]" + diagnostic.code == "access_profile.permission.target_exclusive" + && diagnostic.path == "project.accessProfiles[].permissions[]" })); } @@ -3192,7 +3196,7 @@ fn manifest_projection_filters_by_selected_profile_and_classification_ceiling() ]} ], "accessProfiles":[{ - "id":"operator","principalClaim":"principal","grants":[ + "id":"operator","principalClaim":"principal","permissions":[ {"entity":"visible-target","operations":["get"],"readableFields":["label"], "rowBoundaries": []}, {"entity":"hidden-target","operations":["get"],"readableFields":["label"], "rowBoundaries": []}, {"entity":"link","operations":["get"],"readableFields":["name","operator-note","visible-ref","hidden-ref"], "rowBoundaries": []} @@ -3266,8 +3270,8 @@ fn manifest_projection_metadata_cannot_describe_hidden_entities_or_fields() { "fields":[{"id":"name","type":"string","maxLength":64,"classification":"restricted"}]} ], "accessProfiles":[ - {"id":"reader","principalClaim":"principal","grants":[{"entity":"record","operations":["get"],"readableFields":["name","profile"], "rowBoundaries": []}]}, - {"id":"other-reader","principalClaim":"principal","grants":[{"entity":"secret-record","operations":["get"],"readableFields":["name"], "rowBoundaries": []}]} + {"id":"reader","principalClaim":"principal","permissions":[{"entity":"record","operations":["get"],"readableFields":["name","profile"], "rowBoundaries": []}]}, + {"id":"other-reader","principalClaim":"principal","permissions":[{"entity":"secret-record","operations":["get"],"readableFields":["name"], "rowBoundaries": []}]} ] }"#, ) @@ -3326,7 +3330,7 @@ fn independent_additive_modules_are_order_independent() { "fields":[{"id":"code","type":"string","maxLength":32,"required":true,"classification":"internal"}] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"operator","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"object","operations":["create","get","list","patch"], "readableFields":["code"],"writableFields":["code"], "rowBoundaries": [] @@ -3380,7 +3384,7 @@ fn project_access_profile_required_scopes_compile_into_each_grant() { "accessProfiles":[{ "id":"operator","principalClaim":"registry_principal", "requiredScopes":["registry:record:operate"], - "grants":[{ + "permissions":[{ "entity":"record","operations":["get"],"readableFields":["code"], "rowBoundaries": [] }] @@ -3606,7 +3610,7 @@ fn generic_decimal_crs84_point_and_structured_fields_compile_to_deterministic_dd ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"reading","operations":["create","get","list","patch"], "readableFields":["amount","location","payload"], "writableFields":["amount","location","payload"], @@ -3833,7 +3837,7 @@ fn generic_scalar_option_and_schema_negatives_fail_before_ddl_generation() { "id":"reading","primaryDataset":"test-dataset","route":"readings","mutationMode":"mutable", "fields":[{field}] }}], - "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"reading","operations":["get"],"readableFields":["{}"]}}]}}] + "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"reading","operations":["get"],"readableFields":["{}"]}}]}}] }}"#, if field.contains("\"amount\"") { "amount" @@ -3874,7 +3878,7 @@ fn crs84_point_and_structured_fields_cannot_be_row_boundaries_until_equality_is_ "fields":[{field}] }}], "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{{ "entity":"reading","operations":["get"], "readableFields":["{field_id}"], "rowBoundaries":[{{"field":"{field_id}","claim":"claim","operator":"equals"}}] @@ -3918,7 +3922,7 @@ fn geojson_and_bbox_compile_only_for_direct_current_lists() { }] }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["get","list"],"readableFields":["code","location","valid-from","valid-to","scope"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.25,"maximumLatitudeSpanDegrees":1.5}}, "rowBoundaries": [] @@ -3980,7 +3984,7 @@ fn geojson_and_bbox_compile_only_for_direct_current_lists() { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] }], "accessProfiles":[{ - "id":"reader","default":true,"principalClaim":"principal","grants":[{ + "id":"reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"entry","operations":["list"],"readableFields":["code"], "rowBoundaries": [] }] @@ -4072,7 +4076,7 @@ fn bbox_authoring_requires_declared_readable_primary_point_and_bounded_spans() { {geojson} }}], "accessProfiles":[{{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"site",{grant} }}] }}] @@ -4103,7 +4107,7 @@ fn bbox_authoring_is_strict_and_does_not_make_points_scalar_query_fields() { "geojson":{"geometryField":"location"} }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["list"],"readableFields":["location"], "spatialQueries":{"bbox":{"geometryField":"location","maximumLongitudeSpanDegrees":2,"maximumLatitudeSpanDegrees":2}}, "rowBoundaries": [] @@ -4136,7 +4140,7 @@ fn bbox_authoring_is_strict_and_does_not_make_points_scalar_query_fields() { "fields":[{{"id":"location","type":"crs84-point","precision":6,"classification":"internal"}}] }}], "accessProfiles":[{{ - "id":"reader","default":true,"principalClaim":"principal","grants":[{{ + "id":"reader","default":true,"principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"site","operations":["list"],"readableFields":["location"],{member} }}] }}] @@ -4170,7 +4174,7 @@ fn anonymous_bbox_queries_cannot_process_hidden_geometry() { "geojson":{"geometryField":"location"} }], "accessProfiles":[{ - "id":"public-map","default":true,"anonymous":true,"grants":[{ + "id":"public-map","default":true,"anonymous":true,"permissions":[{ "entity":"site","operations":["list"],"readableFields":["code","location"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":2,"maximumLatitudeSpanDegrees":2}}, "rowBoundaries": [] @@ -4209,7 +4213,7 @@ fn modules_can_add_geojson_once_but_conflicting_geometry_is_refused() { ] }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["list"],"readableFields":["code","location"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":2,"maximumLatitudeSpanDegrees":2}}, "rowBoundaries": [] @@ -4827,7 +4831,7 @@ fn anonymous_profiles_cannot_inherit_partial_unique_processing_over_non_public_f }] }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"entry","operations":["get"],"readableFields":["code"], "rowBoundaries": [] }] @@ -4874,7 +4878,7 @@ fn anonymous_public_surface_rejects_every_non_public_constraint_field() { ] }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"record","operations":["get"],"readableFields":["label"], "rowBoundaries": [] }] @@ -4961,7 +4965,7 @@ fn compiled_partial_unique_constraint_keeps_closed_predicates_in_the_model() { }] }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"entry","operations":["get"],"readableFields":["code","status"],"filterableFields":["status"], "rowBoundaries": [] }] @@ -5001,7 +5005,7 @@ fn create_only_operation_conflict_fails_before_artifact_generation() { .first_mut() .expect("fixture has an access profile"); let grant = profile - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "inspection-event") .expect("fixture grants the create-only entity"); @@ -5214,7 +5218,7 @@ fn generated_openapi_separates_security_and_mutation_input_from_read_schema() { "id":"public", "default":true, "anonymous":true, - "grants":[{ + "permissions":[{ "entity":"business-record", "operations":["get","list"], "readableFields":["code","business-note"], @@ -5224,7 +5228,7 @@ fn generated_openapi_separates_security_and_mutation_input_from_read_schema() { "id":"business", "principalClaim":"registry_principal", "requiredPurposes":["business"], - "grants":[{ + "permissions":[{ "entity":"business-record", "operations":["create","get"], "readableFields":["code","business-note"], @@ -5452,7 +5456,7 @@ fn compiler_produces_both_revision_routes_when_explicitly_configured() { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] }], "accessProfiles":[{ - "id":"auditor","default":true,"principalClaim":"principal","grants":[{ + "id":"auditor","default":true,"principalClaim":"principal","permissions":[{ "entity":"entry","operations":["revisions"],"revisionAccess":true,"readableFields":["code"], "rowBoundaries": [] }] @@ -5558,7 +5562,7 @@ fn compiler_omits_revision_routes_when_not_configured_or_revision_access_is_fals "fields":[{{"id":"code","type":"string","maxLength":32,"classification":"public"}}] }}], "accessProfiles":[{{ - "id":"reader","default":true,"anonymous":{anonymous},{principal_claim}"grants":[{{ + "id":"reader","default":true,"anonymous":{anonymous},{principal_claim}"permissions":[{{ "rowBoundaries": [], "entity":"entry","operations":{operations},"revisionAccess":{revision_access},"readableFields":["code"] }}] }}] @@ -5597,10 +5601,13 @@ fn public_profile_cannot_process_an_internal_field() { id: "public-reader".to_owned(), default: false, anonymous: true, + actor_kind: None, + requester_clients: Default::default(), + task_grant: None, principal_claim: None, required_scopes: Default::default(), required_purposes: Default::default(), - grants: vec![AccessGrantSource { + permissions: vec![AccessPermissionSource { membership_boundaries: Vec::new(), entity: "asset-item".to_owned(), action: None, @@ -5657,7 +5664,7 @@ fn anonymous_public_profile_cannot_filter_a_non_public_field() { ] }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"entry","operations":["list"],"readableFields":["label"], "filterableFields":["hidden-filter-canary"], "rowBoundaries": [] @@ -5711,7 +5718,7 @@ fn additive_module_conflicts_fail_instead_of_using_input_precedence() { "entities":[{"id":"object","primaryDataset":"test-dataset","route":"objects","mutationMode":"mutable","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} ]}], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{"entity":"object","operations":["get"],"readableFields":["code"], "rowBoundaries": []}]}] + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{"entity":"object","operations":["get"],"readableFields":["code"], "rowBoundaries": []}]}] }"#, ) .expect("project parses"); @@ -5747,7 +5754,7 @@ fn operation_ids_preserve_distinct_valid_entity_ids_without_collisions() { {"id":"code","type":"string","maxLength":8,"classification":"internal"} ]} ], - "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + "accessProfiles":[{"id":"reader","principalClaim":"principal","permissions":[ {"entity":"case-file","operations":["get"],"readableFields":["code"], "rowBoundaries": []}, {"entity":"case_file","operations":["get"],"readableFields":["code"], "rowBoundaries": []} ]}] @@ -5895,7 +5902,7 @@ fn compiled_query_inventory_is_profile_scoped_bounded_and_temporal() { .access_profiles .first_mut() .expect("fixture has an access profile") - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "asset-placement") .expect("fixture grants placement access"); @@ -6148,7 +6155,7 @@ fn temporal_validity_compiles_without_non_overlap_constraint() { "temporal":{"startField":"valid-from","endField":"valid-to"} }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"membership","operations":["list"], "readableFields":["person","role","valid-from","valid-to"], "rowBoundaries": [] @@ -6200,7 +6207,7 @@ fn deprecated_temporal_scope_fields_must_match_explicit_non_overlap() { ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"membership","operations":["list"], "readableFields":["person","household","valid-from","valid-to"], "rowBoundaries": [] @@ -6232,7 +6239,7 @@ fn anonymous_temporal_processing_floor_survives_without_exclusion() { "temporal":{"startField":"valid-from","endField":"valid-to"} }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"membership","operations":["list"],"readableFields":["label"], "rowBoundaries": [] }] @@ -6267,7 +6274,7 @@ fn snapshot_operation_is_authenticated_stored_field_history_contract() { }] }], "accessProfiles":[{ - "id":"historian","default":true,"principalClaim":"principal","grants":[{ + "id":"historian","default":true,"principalClaim":"principal","permissions":[{ "entity":"household","operations":["list","snapshot","revisions"],"revisionAccess":true, "readableFields":["household-code","administrative-area","valid-from","valid-to","member-count"], "filterableFields":["administrative-area","member-count"], @@ -6484,7 +6491,7 @@ fn snapshot_operation_rejects_anonymous_and_unauthorized_provenance() { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"public"}] }], "accessProfiles":[{ - "id":"public-reader","anonymous":true,"default":true,"grants":[{ + "id":"public-reader","anonymous":true,"default":true,"permissions":[{ "entity":"record","operations":["snapshot"],"readableFields":["code"], "rowBoundaries": [] }] @@ -6507,7 +6514,7 @@ fn snapshot_operation_rejects_anonymous_and_unauthorized_provenance() { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] }], "accessProfiles":[{ - "id":"reader","default":true,"principalClaim":"principal","grants":[{ + "id":"reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"record","operations":["snapshot"],"readableFields":["code"], "provenanceFields":["kind"], "rowBoundaries": [] @@ -6539,7 +6546,7 @@ fn snapshot_valid_at_openapi_schema_matches_temporal_value_type() { "temporal":{"startField":"valid-from","endField":"valid-to"} }], "accessProfiles":[{ - "id":"historian","default":true,"principalClaim":"principal","grants":[{ + "id":"historian","default":true,"principalClaim":"principal","permissions":[{ "entity":"record","operations":["snapshot"],"allowCount":true, "readableFields":["code","valid-from","valid-to"], "rowBoundaries": [] @@ -6736,7 +6743,7 @@ fn query_inventory_rejects_unsupported_filter_and_sort_field_types() { ] }}], "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{{ "rowBoundaries": [], "entity":"entry","operations":["list"],"readableFields":["payload"],{member} }}] }}] @@ -6759,7 +6766,7 @@ fn temporal_queries_require_profile_readable_boundary_fields() { .access_profiles .first_mut() .expect("fixture has an access profile") - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "asset-placement") .expect("fixture grants placement access"); @@ -6787,7 +6794,7 @@ fn reordered_stored_field_authoring_changes_revision_but_not_query_inventory() { ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"entry","operations":["list"], "readableFields":["code","count"],"filterableFields":["count","code"],"sortableFields":["count","code"], "rowBoundaries": [] @@ -6808,7 +6815,7 @@ fn reordered_stored_field_authoring_changes_revision_but_not_query_inventory() { ] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"entry","operations":["list"], "readableFields":["count","code"],"filterableFields":["code","count"],"sortableFields":["code","count"], "rowBoundaries": [] @@ -6844,7 +6851,7 @@ fn duplicate_routes_fail_before_artifact_generation() { {"id":"code","type":"string","maxLength":8,"classification":"internal"} ]} ], - "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[ + "accessProfiles":[{"id":"reader","principalClaim":"principal","permissions":[ {"entity":"first-record","operations":["get"],"readableFields":["code"], "rowBoundaries": []}, {"entity":"second-record","operations":["get"],"readableFields":["code"], "rowBoundaries": []} ]}] @@ -6877,7 +6884,7 @@ fn anonymous_profiles_cannot_grant_mutation_operations() { "fields":[{"id":"label","type":"string","maxLength":32,"classification":"public"}] }], "accessProfiles":[{ - "id":"anonymous-writer","anonymous":true,"default":true,"grants":[{ + "id":"anonymous-writer","anonymous":true,"default":true,"permissions":[{ "entity":"public-entry","operations":["create","patch"],"readableFields":["label"],"writableFields":["label"], "rowBoundaries": [] }] @@ -6910,7 +6917,7 @@ fn production_refuses_a_digest_present_lock_without_module_source() { "entities":[{"id":"object","primaryDataset":"test-dataset","route":"objects","mutationMode":"create_only","fields":[ {"id":"code","type":"string","maxLength":8,"classification":"internal"} ]}], - "accessProfiles":[{"id":"reader","principalClaim":"principal","grants":[{"entity":"object","operations":["get"],"readableFields":["code"], "rowBoundaries": []}]}] + "accessProfiles":[{"id":"reader","principalClaim":"principal","permissions":[{"entity":"object","operations":["get"],"readableFields":["code"], "rowBoundaries": []}]}] }"#, ) .expect("project parses"); @@ -7001,7 +7008,7 @@ fn selector_project(fields: &str) -> Vec { "selectorProfiles":[{{"id":"by-code","fields":{fields}}}] }}], "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"sub","grants":[{{ + "id":"operator","default":true,"principalClaim":"sub","permissions":[{{ "rowBoundaries": [], "entity":"record","operations":["get"],"readableFields":["code","area"] }}] }}] @@ -7072,7 +7079,7 @@ fn entity_classification_defaults_while_field_classification_stays_explicit() { "fields":[{"id":"code","type":"string","maxLength":32,"classification":"internal"}] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"sub","grants":[{ + "id":"operator","default":true,"principalClaim":"sub","permissions":[{ "entity":"record","operations":["get"],"readableFields":["code"], "rowBoundaries": [] }] diff --git a/crates/registry-breg/tests/compiler_request_attachments.rs b/crates/registry-breg/tests/compiler_request_attachments.rs index b6f9a8c21f..a856ba0d5f 100644 --- a/crates/registry-breg/tests/compiler_request_attachments.rs +++ b/crates/registry-breg/tests/compiler_request_attachments.rs @@ -28,7 +28,7 @@ fn source() -> Value { } }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"request", "operations":["get","list","create","patch","submit_request","approve_request","reject_request","request_revision","apply_request"], "readableFields":["item","label","supporting-file"], @@ -87,7 +87,7 @@ fn request_slots_keep_field_authority_without_creating_scalar_query_columns() { .change_request .as_ref() .unwrap() - .review_grants + .review_permissions .iter() .any( |grant| grant.target_entity_id == "request" && grant.readable_fields.contains(&slot.id) @@ -97,7 +97,7 @@ fn request_slots_keep_field_authority_without_creating_scalar_query_columns() { assert!(!query.processing_fields.contains(&slot.id)); } let mut only_slot = source(); - only_slot["accessProfiles"][0]["grants"][0]["writableFields"] = json!(["supporting-file"]); + only_slot["accessProfiles"][0]["permissions"][0]["writableFields"] = json!(["supporting-file"]); compile(&only_slot).expect("slot-only writable projections are valid"); } @@ -132,12 +132,12 @@ fn every_slot_policy_member_changes_compiled_and_request_contract_identity() { } let mut renamed = original.clone(); renamed["entities"][1]["attachments"][0]["id"] = json!("replacement-file"); - renamed["accessProfiles"][0]["grants"][0]["readableFields"] = + renamed["accessProfiles"][0]["permissions"][0]["readableFields"] = json!(["item", "label", "replacement-file"]); - renamed["accessProfiles"][0]["grants"][0]["writableFields"] = + renamed["accessProfiles"][0]["permissions"][0]["writableFields"] = json!(["item", "label", "replacement-file"]); - renamed["accessProfiles"][0]["grants"][0]["reviewStages"][0]["targets"][1]["readableFields"] = - json!(["replacement-file"]); + renamed["accessProfiles"][0]["permissions"][0]["reviewStages"][0]["targets"][1] + ["readableFields"] = json!(["replacement-file"]); let compiled = compile(&renamed).unwrap(); assert_ne!(compiled.revision(), baseline.revision()); assert_ne!( @@ -157,9 +157,9 @@ fn absence_omits_attachment_members_from_source_and_compiled_contracts() { .as_object_mut() .unwrap() .remove("attachments"); - source["accessProfiles"][0]["grants"][0]["readableFields"] = json!(["item", "label"]); - source["accessProfiles"][0]["grants"][0]["writableFields"] = json!(["item", "label"]); - source["accessProfiles"][0]["grants"][0]["reviewStages"][0]["targets"] + source["accessProfiles"][0]["permissions"][0]["readableFields"] = json!(["item", "label"]); + source["accessProfiles"][0]["permissions"][0]["writableFields"] = json!(["item", "label"]); + source["accessProfiles"][0]["permissions"][0]["reviewStages"][0]["targets"] .as_array_mut() .unwrap() .pop(); @@ -305,7 +305,7 @@ fn content_types_are_bounded_concrete_and_unique() { fn attachments_cannot_be_anonymous_or_scalar_query_inputs() { for member in ["filterableFields", "sortableFields"] { let mut candidate = source(); - candidate["accessProfiles"][0]["grants"][0][member] = json!(["supporting-file"]); + candidate["accessProfiles"][0]["permissions"][0][member] = json!(["supporting-file"]); assert_diagnostic( &candidate, "attachment.access.processing_unsupported", @@ -315,7 +315,7 @@ fn attachments_cannot_be_anonymous_or_scalar_query_inputs() { ); } let mut candidate = source(); - candidate["accessProfiles"][0]["grants"][0]["rowBoundaries"] = + candidate["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = json!([{"field":"supporting-file", "claim":"owner", "operator":"equals"}]); assert_diagnostic( &candidate, diff --git a/crates/registry-breg/tests/compiler_rhai_planner.rs b/crates/registry-breg/tests/compiler_rhai_planner.rs index f61b251414..6d387dbefc 100644 --- a/crates/registry-breg/tests/compiler_rhai_planner.rs +++ b/crates/registry-breg/tests/compiler_rhai_planner.rs @@ -95,9 +95,9 @@ fn plan(script: &str, mode: CompiledChangeRequestApplicationMode) -> CompiledCha effects: Vec::new(), stages: Vec::new(), actions: Vec::new(), - review_grants: Vec::new(), - apply_grants: Vec::new(), - presence_grants: Vec::new(), + review_permissions: Vec::new(), + apply_permissions: Vec::new(), + presence_permissions: Vec::new(), target_entities: BTreeSet::from(["record".to_owned()]), maximum_targets: 16, maximum_field_mutations: 128, @@ -564,7 +564,7 @@ fn rhai_planner_contract_fingerprint_binds_governed_meaning_only() { let mut review_policy = base.clone(); review_policy["entities"][1]["changeRequest"]["review"] = json!({"stages": [{"id": "review", "approvals": 1}]}); - let submitter_grant = &mut review_policy["accessProfiles"][1]["grants"][0]; + let submitter_grant = &mut review_policy["accessProfiles"][1]["permissions"][0]; submitter_grant["operations"] = json!([ "create", "get", @@ -819,7 +819,7 @@ fn anonymous_presence_rejects_a_non_public_rhai_target_link() { .push(json!({ "id": "public-person-reader", "anonymous": true, - "grants": [{ + "permissions": [{ "entity": "person", "operations": ["get", "list"], "readableFields": ["person-code"], @@ -862,8 +862,8 @@ fn automatic_apply_requires_same_profile_trigger_and_target_authority() { .iter_mut() .find(|profile| profile["id"] == "name-change-submitter") .expect("submitter profile exists"); - submitter["grants"][0]["operations"] = json!(["create", "get", "submit_request"]); - submitter["grants"][0] + submitter["permissions"][0]["operations"] = json!(["create", "get", "submit_request"]); + submitter["permissions"][0] .as_object_mut() .expect("grant is an object") .remove("applyTargets"); @@ -918,7 +918,7 @@ fn staged_planner_final_review_cannot_borrow_a_separate_apply_profile() { profiles.push(json!({ "id": "final-reviewer-without-apply", "principalClaim": "registry_principal", - "grants": [{ + "permissions": [{ "entity": "person-name-change-request", "operations": ["get", "approve_request"], "readableFields": ["person", "given-name", "family-name", "handling"], @@ -932,7 +932,7 @@ fn staged_planner_final_review_cannot_borrow_a_separate_apply_profile() { profiles.push(json!({ "id": "separate-staged-applier", "principalClaim": "registry_principal", - "grants": [{ + "permissions": [{ "entity": "person-name-change-request", "operations": ["get", "apply_request"], "readableFields": ["person", "given-name", "family-name", "handling"], diff --git a/crates/registry-breg/tests/compiler_submitter_targets.rs b/crates/registry-breg/tests/compiler_submitter_targets.rs index 3a70476d86..44e697ca72 100644 --- a/crates/registry-breg/tests/compiler_submitter_targets.rs +++ b/crates/registry-breg/tests/compiler_submitter_targets.rs @@ -39,10 +39,10 @@ fn native_reference_admission_requires_complete_manual_same_profile_authority() .find(|profile| profile["id"] == "holder") .unwrap(); match case { - "missing-get" => holder["grants"][0]["operations"] = json!(["list"]), - "unknown-target" => holder["grants"][1]["submitterTargets"] = json!(["unknown"]), + "missing-get" => holder["permissions"][0]["operations"] = json!(["list"]), + "unknown-target" => holder["permissions"][1]["submitterTargets"] = json!(["unknown"]), "unreadable-reference" => { - holder["grants"][1]["readableFields"] = json!([ + holder["permissions"][1]["readableFields"] = json!([ "licensed-activities", "authorization-conditions", "reason", @@ -54,14 +54,14 @@ fn native_reference_admission_requires_complete_manual_same_profile_authority() json!("automatic") } "request-target" => { - holder["grants"][1]["submitterTargets"] = json!(["scope-correction"]); + holder["permissions"][1]["submitterTargets"] = json!(["scope-correction"]); candidate["entities"][1]["fields"][0]["target"] = json!("scope-correction"); } "optional-reference" => { candidate["entities"][1]["fields"][0]["required"] = json!(false) } "unwritable-reference" => { - holder["grants"][1]["writableFields"] = json!([ + holder["permissions"][1]["writableFields"] = json!([ "licensed-activities", "authorization-conditions", "reason", diff --git a/crates/registry-breg/tests/compiler_webhook.rs b/crates/registry-breg/tests/compiler_webhook.rs index 54fd697782..2e618d9357 100644 --- a/crates/registry-breg/tests/compiler_webhook.rs +++ b/crates/registry-breg/tests/compiler_webhook.rs @@ -126,7 +126,7 @@ fn change_request_event_project() -> Value { "id":"submitter", "default":true, "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["create","get","list","patch","submit_request","revise_request","cancel_request"], "readableFields":["placement","proposed-site","reason"], @@ -136,7 +136,7 @@ fn change_request_event_project() -> Value { },{ "id":"reviewer", "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["placement","proposed-site","reason"], @@ -149,7 +149,7 @@ fn change_request_event_project() -> Value { },{ "id":"applier", "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","list","apply_request"], "readableFields":["placement","proposed-site","reason"], diff --git a/crates/registry-breg/tests/data_operations.rs b/crates/registry-breg/tests/data_operations.rs index 2674e76660..e973f888a0 100644 --- a/crates/registry-breg/tests/data_operations.rs +++ b/crates/registry-breg/tests/data_operations.rs @@ -49,7 +49,7 @@ fn compiled(allow_data_export: bool) -> registry_breg::CompiledRegistry { "accessProfiles": [{ "id": PROFILE, "principalClaim": "principal", - "grants": [{ + "permissions": [{ "entity": ENTITY, "operations": ["create", "patch", "batch", "list"], "readableFields": ["code", "count", "readonly"], @@ -109,7 +109,7 @@ fn wide_export_registry() -> registry_breg::CompiledRegistry { "accessProfiles": [{ "id": PROFILE, "principalClaim": "principal", - "grants": [{ + "permissions": [{ "entity": WIDE_ENTITY, "operations": ["list"], "readableFields": ["payload"], @@ -178,7 +178,7 @@ fn data_export_requires_explicit_nonanonymous_profile_permission() { "accessProfiles": [{ "id": PROFILE, "anonymous": anonymous, "principalClaim": if anonymous { Value::Null } else { json!("principal") }, - "grants": [{ + "permissions": [{ "rowBoundaries": [], "entity": ENTITY, "operations": operations, "readableFields": readable, "allowDataExport": true @@ -210,7 +210,7 @@ fn data_export_requires_explicit_nonanonymous_profile_permission() { "registry": {"id": "project-export", "version": "1", "defaultLanguage": "en", "canonicalBaseIri": "https://authoring.example.test"}, "accessProfiles": [{ "id": "project-exporter", "principalClaim": "principal", - "grants": [{"entity": ENTITY, "operations": ["list"], + "permissions": [{"entity": ENTITY, "operations": ["list"], "readableFields": ["code"], "allowDataExport": true, "rowBoundaries": []}] }], "entities": [{ @@ -341,7 +341,7 @@ fn data_validate_and_chunk_plan_reuse_runtime_rules_and_compiled_batch_bounds() "batch":{"maximumItems":2,"maximumBytes":100}, "fields":[{"id":"code","type":"text","maxLength":1000,"required":true, "classification":"internal"}]}], - "accessProfiles":[{"id":PROFILE,"principalClaim":"principal","grants":[{ + "accessProfiles":[{"id":PROFILE,"principalClaim":"principal","permissions":[{ "entity":ENTITY, "operations":["create","batch"],"readableFields":["code"], "writableFields":["code"], "rowBoundaries": []}]}] @@ -392,7 +392,7 @@ fn data_lifecycle_uses_exact_compiled_api_names() { "accessProfiles": [{ "id": PROFILE, "principalClaim": "principal", - "grants": [{ + "permissions": [{ "entity": ENTITY, "operations": ["create", "patch", "batch", "list"], "readableFields": ["record-code"], diff --git a/crates/registry-breg/tests/explicit_row_authority.rs b/crates/registry-breg/tests/explicit_row_authority.rs index cbf31f940a..7443807e71 100644 --- a/crates/registry-breg/tests/explicit_row_authority.rs +++ b/crates/registry-breg/tests/explicit_row_authority.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 use registry_breg::contract::{ - AccessGrantSource, AccessProfileSource, AccessRequirementsSource, ActionTargetGrantSource, - ApplyTargetGrantSource, RequestPresenceGrantSource, ReviewStageTargetGrantSource, + AccessPermissionSource, AccessProfileSource, AccessRequirementsSource, + ActionTargetPermissionSource, ApplyTargetPermissionSource, RequestPresencePermissionSource, + ReviewStageTargetPermissionSource, }; use serde::de::DeserializeOwned; use serde_json::{json, Value}; @@ -21,20 +22,20 @@ fn requires_explicit_rows(mut value: Value) { #[test] fn every_row_bearing_grant_requires_an_explicit_declaration() { - requires_explicit_rows::(json!({ + requires_explicit_rows::(json!({ "entity":"record", "operations":["get"] })); - requires_explicit_rows::(json!({"entity":"record"})); - requires_explicit_rows::(json!({"entity":"record"})); - requires_explicit_rows::(json!({ + requires_explicit_rows::(json!({"entity":"record"})); + requires_explicit_rows::(json!({"entity":"record"})); + requires_explicit_rows::(json!({ "entity":"record", "readableFields":["label"] })); - requires_explicit_rows::(json!({"requestType":"correction"})); + requires_explicit_rows::(json!({"requestType":"correction"})); } #[test] fn invocation_and_mandatory_requirements_do_not_invent_row_grants() { - let action: AccessGrantSource = serde_json::from_value(json!({ + let action: AccessPermissionSource = serde_json::from_value(json!({ "action":"register", "operations":["invoke"], "targets":[{"entity":"record", "rowBoundaries":[]}] })) @@ -58,9 +59,9 @@ fn membership_preserves_explicit_row_declarations_and_round_trips() { let mut grant = json!({ "entity":"record", "operations":["get"], "membershipBoundaries": boundaries }); - requires_explicit_rows::(grant.clone()); + requires_explicit_rows::(grant.clone()); grant["rowBoundaries"] = json!([]); - let parsed: AccessGrantSource = serde_json::from_value(grant).unwrap(); + let parsed: AccessPermissionSource = serde_json::from_value(grant).unwrap(); assert_eq!(parsed.membership_boundaries.len(), 1); assert_eq!( serde_json::to_value(parsed).unwrap()["membershipBoundaries"], diff --git a/crates/registry-breg/tests/fixture_tooling.rs b/crates/registry-breg/tests/fixture_tooling.rs index be9fd93725..c44605f8d8 100644 --- a/crates/registry-breg/tests/fixture_tooling.rs +++ b/crates/registry-breg/tests/fixture_tooling.rs @@ -925,7 +925,7 @@ fn compiled_contact_request_fixture() -> registry_breg::CompiledRegistry { .unwrap() .clone(); limited_applier.id = "household-contact-limited-applier".to_owned(); - limited_applier.grants[0] + limited_applier.permissions[0] .apply_targets .retain(|target| target.entity != "person"); project.access_profiles.push(limited_applier); @@ -1174,18 +1174,18 @@ fn compiled_request_fixture() -> registry_breg::CompiledRegistry { } }], "accessProfiles":[{ - "id":"submitter","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"submitter","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"correction-request","operations":["create","submit_request","revise_request","cancel_request"],"readableFields":["target","value"],"writableFields":["target","value"], "rowBoundaries": [] }] },{ - "id":"reviewer","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"correction-request","operations":["get","list","approve_request","reject_request","request_revision"],"readableFields":["target","value"], "reviewStages":[{"stage":"review","targets":[{"entity":"target","readableFields":["label"],"rowBoundaries":[]}]}], "rowBoundaries": [] }] },{ - "id":"applier","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"applier","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"correction-request","operations":["apply_request"],"readableFields":["target"], "applyTargets":[{"entity":"target","rowBoundaries":[]}], "rowBoundaries": [] @@ -1214,7 +1214,7 @@ fn compiled_crud_alias_fixture() -> registry_breg::CompiledRegistry { }], "accessProfiles":[{ "id":"registrar","default":true,"principalClaim":"registry_principal","requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"person","operations":["create","get","list","patch"], "readableFields":["jurisdiction","person-code","legal-name"], "writableFields":["jurisdiction","person-code","legal-name"], @@ -1278,14 +1278,14 @@ fn compiled_action_fixture() -> registry_breg::CompiledRegistry { }], "accessProfiles":[{ "id":"household-seed","default":true,"principalClaim":"registry_principal","requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"household","operations":["create","get"],"readableFields":["jurisdiction","household-code","contact-person"], "writableFields":["jurisdiction","household-code"],"rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] }] },{ "id":"contact-registrar","default":true,"principalClaim":"registry_principal", "requiredScopes":["registry:contact:register"],"requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"register-household-contact","operations":["invoke"], "targets":[ {"entity":"household","rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}]}, @@ -1296,7 +1296,7 @@ fn compiled_action_fixture() -> registry_breg::CompiledRegistry { }] },{ "id":"person-reader","default":true,"principalClaim":"registry_principal","requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"person","operations":["get"],"readableFields":["jurisdiction","person-code","legal-name"], "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdiction","operator":"equals"}] }] @@ -1330,12 +1330,12 @@ fn compiled_spatial_fixture() -> registry_breg::CompiledRegistry { "geojson":{"geometryField":"location"} }], "accessProfiles":[ - {"id":"map-reader","default":true,"anonymous":true,"grants":[{ + {"id":"map-reader","default":true,"anonymous":true,"permissions":[{ "entity":"site","operations":["get","list"],"readableFields":["code","location"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":1,"maximumLatitudeSpanDegrees":1}}, "rowBoundaries": [] }]}, - {"id":"directory-reader","anonymous":true,"grants":[{ + {"id":"directory-reader","anonymous":true,"permissions":[{ "entity":"site","operations":["list"],"readableFields":["code"], "rowBoundaries": [] }]} diff --git a/crates/registry-breg/tests/fixtures/action-authority-mapping.yaml b/crates/registry-breg/tests/fixtures/action-authority-mapping.yaml index 30d06c862f..bd9f94c15b 100644 --- a/crates/registry-breg/tests/fixtures/action-authority-mapping.yaml +++ b/crates/registry-breg/tests/fixtures/action-authority-mapping.yaml @@ -26,7 +26,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [case.rename] requiredPurposes: [case-management] - grants: + permissions: - action: rename-case operations: [invoke] targets: @@ -37,7 +37,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [case.supervise] requiredPurposes: [case-management] - grants: + permissions: - action: rename-case operations: [invoke] targets: [{entity: case, rowBoundaries: []}] diff --git a/crates/registry-breg/tests/fixtures/authority-mapping.yaml b/crates/registry-breg/tests/fixtures/authority-mapping.yaml index e94551a7d7..c37388c654 100644 --- a/crates/registry-breg/tests/fixtures/authority-mapping.yaml +++ b/crates/registry-breg/tests/fixtures/authority-mapping.yaml @@ -35,7 +35,7 @@ accessProfiles: - id: operator default: true principalClaim: registry_principal - grants: + permissions: - entity: asset operations: [get, lookup] readableFields: [label] @@ -50,7 +50,7 @@ accessProfiles: - id: reviewer default: true principalClaim: registry_principal - grants: + permissions: - entity: correction operations: [get, submit_request, approve_request, reject_request, request_revision, apply_request] readableFields: [asset, label] diff --git a/crates/registry-breg/tests/fixtures/fixed-optional-action-input.yaml b/crates/registry-breg/tests/fixtures/fixed-optional-action-input.yaml index c650e75f62..5e908c0318 100644 --- a/crates/registry-breg/tests/fixtures/fixed-optional-action-input.yaml +++ b/crates/registry-breg/tests/fixtures/fixed-optional-action-input.yaml @@ -21,7 +21,7 @@ accessProfiles: - id: registrar default: true principalClaim: registry_principal - grants: + permissions: - action: create-entry operations: [invoke] targets: [{entity: entry, rowBoundaries: []}] diff --git a/crates/registry-breg/tests/fixtures/fixture-tooling/project.yaml b/crates/registry-breg/tests/fixtures/fixture-tooling/project.yaml index b489457c4f..8c7bd420a4 100644 --- a/crates/registry-breg/tests/fixtures/fixture-tooling/project.yaml +++ b/crates/registry-breg/tests/fixtures/fixture-tooling/project.yaml @@ -54,7 +54,7 @@ accessProfiles: default: true principalClaim: registry_principal requiredPurposes: [case-management] - grants: + permissions: - entity: widget operations: [create, get, list, patch, batch] readableFields: [jurisdiction, label, note, quantity] diff --git a/crates/registry-breg/tests/http_auth.rs b/crates/registry-breg/tests/http_auth.rs index ba4c3503bd..35bd07b258 100644 --- a/crates/registry-breg/tests/http_auth.rs +++ b/crates/registry-breg/tests/http_auth.rs @@ -26,7 +26,7 @@ use registry_breg::{compile_project, parse_project_yaml, CompileProfile, Compile use registry_platform_crypto::PrivateJwk; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ - access_token_typ_set, JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, + access_token_typ_set, ClaimNames, JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, }; use registry_platform_testing::{ @@ -42,6 +42,7 @@ const PURPOSE: &str = "case-management-never-rendered"; const JURISDICTION: &str = "area-a-never-rendered"; const TENANT: &str = "tenant-a-never-rendered"; const RECORD_ID: &str = "00000000-0000-4000-8000-000000000001"; +const ACTOR_ID: &str = "00000000-0000-4000-8000-0000000000aa"; const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 @@ -62,12 +63,11 @@ entities: - {id: label, type: string, required: true, maxLength: 100, classification: public} - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} - {id: jurisdiction, type: string, required: true, maxLength: 100, classification: internal} - - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: case operations: [get] readableFields: [label] @@ -76,7 +76,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management-never-rendered] - grants: + permissions: - entity: case operations: [get] readableFields: [label, secret] @@ -102,11 +102,12 @@ entities: classification: public fields: - {id: label, type: string, required: true, maxLength: 100, classification: public} + - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} accessProfiles: - id: caseworker default: true principalClaim: registry_principal - grants: + permissions: - entity: case operations: [get] readableFields: [label] @@ -114,6 +115,43 @@ accessProfiles: - {field: id, claim: record_id, operator: equals} "#; +const CONTEXTUAL_PROJECT: &str = r#" +apiVersion: registry.registrystack.org/v1alpha1 +kind: RegistryProject +registry: {id: contextual-auth, version: 0.1.0, defaultLanguage: en, canonicalBaseIri: https://authoring.example.test} +entities: + - id: case + primaryDataset: test-dataset + route: cases + mutationMode: mutable + tombstone: false + classification: public + fields: + - {id: label, type: string, required: true, maxLength: 100, classification: public} + - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} +accessProfiles: + - id: standing-agent + principalClaim: sub + actorKind: agent + requesterClients: [agent-client] + requiredPurposes: [citizen-self-service] + permissions: + - {entity: case, operations: [get], readableFields: [label], rowBoundaries: []} + - id: delegated-agent + default: true + principalClaim: sub + actorKind: agent + requesterClients: [agent-client] + requiredPurposes: [record-review] + taskGrant: {authority: casework-v1, sourceIssuer: https://casework.example} + permissions: + - entity: case + operations: [get] + readableFields: [label] + rowBoundaries: + - {field: tenant, claim: tenant_claim, operator: equals} +"#; + #[derive(Default)] struct RecordingReadService { calls: AtomicUsize, @@ -274,6 +312,170 @@ impl Harness { } } +async fn contextual_harness() -> Harness { + let project = parse_project_yaml(CONTEXTUAL_PROJECT.as_bytes()).expect("project parses"); + let registry = Arc::new( + compile_project(&project, &[], CompileProfile::Authoring).expect("project compiles"), + ); + let idp = MockIdp::start().await; + let mut verifier = verifier_config(&idp); + verifier.allowed_clients = vec!["agent-client".to_owned()]; + let claims = AuthorityClaimConfig::new("sub", Some("registry_purpose".to_owned())) + .with_contextual_claims( + ClaimNames::default(), + BTreeMap::from([("agent-client".to_owned(), ACTOR_ID.to_owned())]), + ); + let authenticator = Arc::new( + authenticator_with_verifier(®istry, &idp, verifier, claims) + .expect("contextual authentication config is valid"), + ); + let records = Arc::new(RecordingReadService::default()); + let service = Arc::new(HttpService::new( + Arc::clone(®istry), + read_identity(), + records.clone(), + Arc::new(Ready), + cursor_codec(), + )); + let app = authenticated_router(service, Arc::clone(&authenticator)); + Harness { + app, + authenticator, + records, + registry, + idp, + } +} + +#[tokio::test] +async fn task_grant_is_exactly_bound_and_cannot_fall_back_to_standing_authority() { + let harness = contextual_harness().await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let claims = json!({ + "aud": AUDIENCE, + "sub": PRINCIPAL, + "azp": "agent-client", + "registry_actor_kind": "agent", + "registry_purpose": "record-review", + "registry_grant_id": "00000000-0000-4000-8000-0000000000bb", + "registry_grant_authority": "casework-v1", + "registry_grant_source_issuer": "https://casework.example", + "registry_grant_client": "agent-client", + "registry_grant_resource": AUDIENCE, + "registry_grant_exp": now + 600, + "registry_grant_bounds": {"type":"breg","permissions":[{"collection":"cases","operations":["get"]}]}, + "tenant_claim": "flat-claim-must-not-control-task-row", + "identity": {"tenant_claim": "tenant-from-approved-identity"} + }); + let token = harness.signed_token(claims.clone(), "JWT"); + let delegated = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=delegated-agent"), + &[bearer(&token)], + None, + ) + .await; + assert_eq!(delegated.status(), StatusCode::OK); + let requests = harness.records.requests.lock().unwrap(); + let context = &requests[0].context; + let task_grant = context.task_grant().expect("task grant is retained"); + assert_eq!( + task_grant.subjects(), + &BTreeMap::from([( + "tenant_claim".to_owned(), + json!("tenant-from-approved-identity"), + )]) + ); + assert_eq!( + context.row_boundaries()[0].values(), + &BTreeSet::from(["tenant-from-approved-identity".to_owned()]) + ); + drop(requests); + + let standing = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=standing-agent"), + &[bearer(&token)], + None, + ) + .await; + assert_ne!(standing.status(), StatusCode::OK); + + let mut wider = claims; + wider["registry_grant_bounds"] = + json!({"type":"breg","permissions":[{"collection":"cases","operations":["get","list"]}]}); + let refused = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=delegated-agent"), + &[bearer(&harness.signed_token(wider, "JWT"))], + None, + ) + .await; + assert_ne!(refused.status(), StatusCode::OK); +} + +#[tokio::test] +async fn standing_citizen_agent_requires_the_registered_client_actor_pair() { + let harness = contextual_harness().await; + let claims = json!({ + "aud": AUDIENCE, + "sub": PRINCIPAL, + "azp": "agent-client", + "registry_actor_kind": "agent", + "registry_purpose": "citizen-self-service", + "act": {"sub": ACTOR_ID} + }); + let accepted = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=standing-agent"), + &[bearer(&harness.signed_token(claims.clone(), "JWT"))], + None, + ) + .await; + assert_eq!(accepted.status(), StatusCode::OK); + + let mut wrong_actor = claims; + wrong_actor["act"] = json!({"sub":"00000000-0000-4000-8000-0000000000cc"}); + let refused = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=standing-agent"), + &[bearer(&harness.signed_token(wrong_actor, "JWT"))], + None, + ) + .await; + assert_eq!(refused.status(), StatusCode::UNAUTHORIZED); +} + +#[test] +fn task_profiles_allow_governed_draft_authoring_and_refuse_direct_target_mutation() { + let direct = CONTEXTUAL_PROJECT.replace( + "operations: [get], readableFields: [label]", + "operations: [create, get], readableFields: [label], writableFields: [label]", + ); + let project = parse_project_yaml(direct.as_bytes()).expect("direct mutation project parses"); + let failure = compile_project(&project, &[], CompileProfile::Authoring) + .expect_err("direct target mutation is refused"); + assert!(failure.diagnostics().iter().any(|diagnostic| { + diagnostic.code == "access_profile.task_grant.direct_mutation_forbidden" + })); + + let governed = include_str!("fixtures/authority-mapping.yaml") + .replace( + " - id: reviewer\n default: true\n principalClaim: registry_principal", + " - id: reviewer\n default: true\n principalClaim: registry_principal\n actorKind: agent\n requesterClients: [agent-client]\n requiredPurposes: [record-review]\n taskGrant: {authority: casework-v1, sourceIssuer: https://casework.example}", + ) + .replace( + "operations: [get, submit_request, approve_request", + "operations: [create, get, patch, submit_request, approve_request", + ); + let project = parse_project_yaml(governed.as_bytes()).expect("governed draft project parses"); + compile_project(&project, &[], CompileProfile::Authoring) + .expect("governed request draft create and patch remain available"); +} + fn read_identity() -> ReadRuntimeIdentity { ReadRuntimeIdentity { package_revision: "package-auth-test".to_owned(), @@ -1076,7 +1278,7 @@ async fn refusals_and_debug_output_are_value_free() { fn action_only_claim_source() -> Value { let mut source = action_source::project(); source["accessProfiles"][0]["requiredPurposes"] = json!([PURPOSE]); - source["accessProfiles"][0]["grants"][0]["targets"][0]["rowBoundaries"][0] = + source["accessProfiles"][0]["permissions"][0]["targets"][0]["rowBoundaries"][0] = json!({"field": "zone", "claim": "allowed_owners", "operator": "in"}); source } @@ -1177,7 +1379,7 @@ async fn action_only_principal_purpose_and_claim_shape_conflicts_are_checked() { let mut conflicting = source; conflicting["accessProfiles"].as_array_mut().unwrap().push(json!({ "id": "reader", "principalClaim": "registry_principal", "requiredScopes": ["registry:parent:process"], - "grants": [{"entity": "parent", "operations": ["get"], "readableFields": ["status"], "rowBoundaries": [{"field": "zone", "claim": "allowed_owners", "operator": "equals"}]}] + "permissions": [{"entity": "parent", "operations": ["get"], "readableFields": ["status"], "rowBoundaries": [{"field": "zone", "claim": "allowed_owners", "operator": "equals"}]}] })); let registry = compile_action_claim_source(&conflicting); assert_eq!( diff --git a/crates/registry-breg/tests/http_read_only.rs b/crates/registry-breg/tests/http_read_only.rs index 3e99da97da..85398ca016 100644 --- a/crates/registry-breg/tests/http_read_only.rs +++ b/crates/registry-breg/tests/http_read_only.rs @@ -61,7 +61,7 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: case rowBoundaries: [] operations: [get, list] @@ -72,7 +72,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: case operations: [create, get, list, patch, tombstone, batch, revisions] allowCount: true @@ -137,7 +137,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: household rowBoundaries: [] operations: [get, lookup, list] @@ -163,7 +163,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: household operations: [get, lookup] readableFields: [household-code] @@ -204,7 +204,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: benefit-record rowBoundaries: [] operations: [get, list] @@ -253,7 +253,7 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: public-record rowBoundaries: [] operations: [get, list] @@ -264,7 +264,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: public-record rowBoundaries: [] operations: [get, list] @@ -304,7 +304,7 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: logical-record rowBoundaries: [] operations: [get, list] @@ -409,7 +409,7 @@ accessProfiles: - id: operator default: true anonymous: true - grants: + permissions: - entity: permit rowBoundaries: [] operations: [get, list] @@ -430,7 +430,7 @@ accessProfiles: readableFields: [import-source, certificate-code] - id: redacted-reader anonymous: true - grants: + permissions: - entity: inspection rowBoundaries: [] operations: [get] @@ -464,7 +464,7 @@ accessProfiles: - id: map-reader default: true anonymous: true - grants: + permissions: - entity: site rowBoundaries: [] operations: [get, list] @@ -476,7 +476,7 @@ accessProfiles: maximumLatitudeSpanDegrees: 2 - id: tabular anonymous: true - grants: + permissions: - entity: site rowBoundaries: [] operations: [get, list] @@ -554,7 +554,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: assignment operations: [snapshot] readableFields: [label, starts, ends] @@ -566,7 +566,7 @@ accessProfiles: - id: live-only principalClaim: registry_principal requiredScopes: [registry.read] - grants: + permissions: - entity: assignment rowBoundaries: [] operations: [get, list] @@ -574,7 +574,7 @@ accessProfiles: - id: revision-only principalClaim: registry_principal requiredScopes: [registry.read] - grants: + permissions: - entity: assignment rowBoundaries: [] operations: [revisions] @@ -617,13 +617,13 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - {entity: placement-correction, rowBoundaries: [], operations: [get, list], readableFields: [target, proposed-site]} - id: correction-officer principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: placement rowBoundaries: [] operations: [get] @@ -643,7 +643,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: placement rowBoundaries: [] operations: [get] @@ -657,7 +657,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: placement-correction rowBoundaries: [] operations: [get, list] @@ -1612,7 +1612,7 @@ async fn lookup_route_conceals_a_substituted_access_profile_selection() { principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: household rowBoundaries: [] operations: [get] @@ -4174,9 +4174,9 @@ async fn attachment_query_metadata_matches_readable_slots_without_scalar_sql_pro .unwrap() .clone(); narrow["id"] = json!("metadata-narrow"); - narrow["grants"][0]["operations"] = json!(["get", "list"]); - narrow["grants"][0]["readableFields"] = json!(["record", "label"]); - narrow["grants"][0]["writableFields"] = json!([]); + narrow["permissions"][0]["operations"] = json!(["get", "list"]); + narrow["permissions"][0]["readableFields"] = json!(["record", "label"]); + narrow["permissions"][0]["writableFields"] = json!([]); source["accessProfiles"] .as_array_mut() .unwrap() diff --git a/crates/registry-breg/tests/immediate_action_compiler.rs b/crates/registry-breg/tests/immediate_action_compiler.rs index fc22a7c917..a2cfe09680 100644 --- a/crates/registry-breg/tests/immediate_action_compiler.rs +++ b/crates/registry-breg/tests/immediate_action_compiler.rs @@ -82,7 +82,7 @@ fn handler_response_schema_accepts_omitted_slots_across_overlapping_grant_result let mut limited = source["accessProfiles"][0].clone(); limited["id"] = json!("person-result-only"); limited["default"] = json!(false); - limited["grants"][0]["results"] = json!(["person"]); + limited["permissions"][0]["results"] = json!(["person"]); source["accessProfiles"] .as_array_mut() .unwrap() @@ -169,7 +169,7 @@ fn household_contact_project(extra: &str) -> String { "principalClaim":"registry_principal", "requiredScopes":["registry:contact:register"], "requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"register-household-contact", "operations":["invoke"], "targets":[ @@ -290,8 +290,8 @@ fn household_contact_action_compiles_routes_effects_and_authority() { vec!["contact-person".to_owned()] ); - assert_eq!(action.grants.len(), 1); - let grant = &action.grants[0]; + assert_eq!(action.permissions.len(), 1); + let grant = &action.permissions[0]; assert_eq!(grant.profile_id, "contact-registrar"); assert!(grant.default); assert_eq!(grant.principal_claim.as_deref(), Some("registry_principal")); @@ -324,13 +324,13 @@ fn action_grants_refuse_request_metadata_projection_overrides() { serde_json::from_str(&household_contact_project("")).unwrap(); compile_json(&serde_json::to_vec(&source).unwrap()) .expect("action grant with omitted request metadata settings compiles"); - source["accessProfiles"][0]["grants"][0]["readableRequestFields"] = serde_json::json!([]); + source["accessProfiles"][0]["permissions"][0]["readableRequestFields"] = serde_json::json!([]); let failure = compile_json(&serde_json::to_vec(&source).unwrap()) .expect_err("request metadata permissions do not apply to immediate actions"); assert!(failure .diagnostics() .iter() - .any(|diagnostic| { diagnostic.code == "action.grant.entity_fields_forbidden" })); + .any(|diagnostic| { diagnostic.code == "action.permission.entity_fields_forbidden" })); } #[test] @@ -353,8 +353,8 @@ fn action_grants_must_cover_every_derived_target_and_result() { .iter() .map(|diagnostic| diagnostic.code.as_str()) .collect::>(); - assert!(codes.contains(&"action.grant.targets.incomplete")); - assert!(codes.contains(&"action.grant.result_unknown")); + assert!(codes.contains(&"action.permission.targets.incomplete")); + assert!(codes.contains(&"action.permission.result_unknown")); } #[test] @@ -399,7 +399,7 @@ fn immediate_actions_preserve_review_control_and_request_lifecycle_boundaries() }], "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"principal", - "grants":[{ + "permissions":[{ "entity":"record-change", "operations":["get","submit_request","approve_request","apply_request"], "readableFields":["record","label"], @@ -480,7 +480,7 @@ fn action_effect_graph_rejects_invalid_sources_cycles_and_overlaps() { "set":{"label":{"fromField":"label"},"alpha":{"fromEffect":"alpha"}} }] }], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{ + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{ "action":"make-cycle", "operations":["invoke"], "targets":[{"entity":"alpha","rowBoundaries":[]},{"entity":"beta","rowBoundaries":[]}] @@ -510,7 +510,7 @@ fn action_inputs_resolve_project_vocabulary_values_for_type_compatibility() { "inputs":[{"id":"kind","type":"vocabulary-code","vocabulary":"asset-type","required":true,"classification":"internal"}], "effects":[{"id":"asset","target":{"entity":"asset"},"operation":"create","set":{"kind":{"fromField":"kind"}}}] }], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{ + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{ "action":"create-asset", "operations":["invoke"], "targets":[{"entity":"asset","rowBoundaries":[]}], @@ -543,7 +543,7 @@ fn action_inputs_reject_unknown_project_vocabulary_references() { "inputs":[{"id":"kind","type":"vocabulary-code","vocabulary":"asset-type","required":true,"classification":"internal"}], "effects":[{"id":"asset","target":{"entity":"asset"},"operation":"create","set":{"kind":{"fromField":"kind"}}}] }], - "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","grants":[{ + "accessProfiles":[{"id":"operator","default":true,"principalClaim":"principal","permissions":[{ "action":"create-asset", "operations":["invoke"], "targets":[{"entity":"asset","rowBoundaries":[]}] @@ -585,7 +585,7 @@ fn action_bounds_apply_before_runtime_target_work() { "fields":[{{"id":"label","type":"string","maxLength":32,"classification":"internal"}}]}}], "actions":[{{"id":"bulk-fix","inputs":[{}],"effects":[{}]}}], "accessProfiles":[{{"id":"operator","default":true,"principalClaim":"principal", - "grants":[{{"action":"bulk-fix","operations":["invoke"],"targets":[{}]}}]}}] + "permissions":[{{"action":"bulk-fix","operations":["invoke"],"targets":[{}]}}]}}] }}"#, inputs.join(","), effects.join(","), @@ -649,7 +649,7 @@ fn action_field_and_snapshot_ceilings_refuse_otherwise_valid_plans() { }], "accessProfiles": [{ "id": "operator", "default": true, "principalClaim": "principal", - "grants": [{ + "permissions": [{ "action": "create-bounded-record", "operations": ["invoke"], "targets": [{"entity": "bounded-record", "rowBoundaries": []}] }] diff --git a/crates/registry-breg/tests/immediate_action_handler.rs b/crates/registry-breg/tests/immediate_action_handler.rs index c11975136c..795822a31c 100644 --- a/crates/registry-breg/tests/immediate_action_handler.rs +++ b/crates/registry-breg/tests/immediate_action_handler.rs @@ -29,7 +29,7 @@ fn project() -> Value { {"id":"friend","target":{"entity":"person"},"operation":"create","fields":["name","friend"]}, {"id":"existing","target":{"fromField":"person"},"operation":"patch","fields":["name","friend"]} ]}}], - "accessProfiles":[{"id":"registrar","default":true,"principalClaim":"principal","grants":[{"action":"register-person","operations":["invoke"],"targets":[{"entity":"person","rowBoundaries":[]}],"results":["person","friend","existing"]}]}] + "accessProfiles":[{"id":"registrar","default":true,"principalClaim":"principal","permissions":[{"action":"register-person","operations":["invoke"],"targets":[{"entity":"person","rowBoundaries":[]}],"results":["person","friend","existing"]}]}] }) } fn compile(source: Value, script: &str) -> Result { @@ -547,7 +547,7 @@ fn handler_compiler_rejects_inputs_outside_the_scalar_abi() { "id":"person","target":{"entity":"person"},"operation":"create", "set":{"name":{"fromField":"name"}} }]); - fixed["accessProfiles"][0]["grants"][0]["results"] = json!(["person"]); + fixed["accessProfiles"][0]["permissions"][0]["results"] = json!(["person"]); let fixed = parse_project_json(&serde_json::to_vec(&fixed).unwrap()).unwrap(); compile_project_with_assets(&fixed, &[], &[], CompileProfile::Authoring).unwrap(); } @@ -1094,7 +1094,7 @@ fn fixed_action_fingerprints_omit_handler_and_bind_native_patterns() { .remove("handler"); source["actions"][0]["inputs"][0]["maxLength"] = json!(160); source["actions"][0]["effects"] = json!([{"id":"person","target":{"entity":"person"},"operation":"create","set":{"name":{"fromField":"given-name"}}}]); - source["accessProfiles"][0]["grants"][0]["results"] = json!(["person"]); + source["accessProfiles"][0]["permissions"][0]["results"] = json!(["person"]); let compile = |source: &Value| { let project = parse_project_json(&serde_json::to_vec(source).unwrap()).unwrap(); registry_breg::compiler::compile_project(&project, &[], CompileProfile::Authoring) @@ -1127,7 +1127,7 @@ fn handler_rejects_reference_to_a_create_of_an_incompatible_entity() { second["route"] = json!("organizations"); source["entities"].as_array_mut().unwrap().push(second); source["actions"][0]["handler"]["writes"][1]["target"]["entity"] = json!("organization"); - source["accessProfiles"][0]["grants"][0]["targets"] + source["accessProfiles"][0]["permissions"][0]["targets"] .as_array_mut() .unwrap() .push(json!({"entity":"organization","rowBoundaries":[]})); diff --git a/crates/registry-breg/tests/immediate_action_requirements.rs b/crates/registry-breg/tests/immediate_action_requirements.rs index a1cc2278e4..3d38648c28 100644 --- a/crates/registry-breg/tests/immediate_action_requirements.rs +++ b/crates/registry-breg/tests/immediate_action_requirements.rs @@ -91,11 +91,11 @@ fn action_requirements_keep_mandatory_scope_and_public_processing_boundaries() { .remove("principalClaim"); let report = format!("{:?}", compile(source).unwrap_err()); assert!( - report.contains("action.grant.anonymous_forbidden"), + report.contains("action.permission.anonymous_forbidden"), "{report}" ); let mut source = support::project(); - source["accessProfiles"][0]["grants"][0]["targets"] = + source["accessProfiles"][0]["permissions"][0]["targets"] = json!([{"entity": "child", "rowBoundaries": []}]); assert!( compile(source).is_err(), diff --git a/crates/registry-breg/tests/membership_access.rs b/crates/registry-breg/tests/membership_access.rs index 6abd6b557c..f521baad27 100644 --- a/crates/registry-breg/tests/membership_access.rs +++ b/crates/registry-breg/tests/membership_access.rs @@ -45,22 +45,22 @@ fn membership_boundaries_compile_for_distinct_registry_models() { fn membership_boundaries_refuse_unenforced_authority_paths() { for (path, value, code) in [ ( - "/accessProfiles/0/grants/0/operations", + "/accessProfiles/0/permissions/0/operations", json!(["get", "patch"]), "access.membership.read_only", ), ( - "/accessProfiles/0/grants/0/reviewStages", + "/accessProfiles/0/permissions/0/reviewStages", json!([{"stage":"review"}]), "access.membership.read_only", ), ( - "/accessProfiles/0/grants/0/applyTargets", + "/accessProfiles/0/permissions/0/applyTargets", json!([{"entity":"facility","rowBoundaries":[]}]), "access.membership.read_only", ), ( - "/accessProfiles/0/grants/0/requestPresence", + "/accessProfiles/0/permissions/0/requestPresence", json!([{"requestType":"request","rowBoundaries":[]}]), "access.membership.read_only", ), @@ -75,22 +75,22 @@ fn membership_boundaries_refuse_unenforced_authority_paths() { "access.requirements.scope_missing", ), ( - "/accessProfiles/0/grants/0/membershipBoundaries/0/field", + "/accessProfiles/0/permissions/0/membershipBoundaries/0/field", json!("label"), "access.membership.key_type", ), ( - "/accessProfiles/0/grants/0/membershipBoundaries/0/principalField", + "/accessProfiles/0/permissions/0/membershipBoundaries/0/principalField", json!("active"), "access.membership.principal_type", ), ( - "/accessProfiles/0/grants/0/membershipBoundaries/0/activeField", + "/accessProfiles/0/permissions/0/membershipBoundaries/0/activeField", json!("principal"), "access.membership.active_type", ), ( - "/accessProfiles/0/grants/0/membershipBoundaries/0/membershipEntity", + "/accessProfiles/0/permissions/0/membershipBoundaries/0/membershipEntity", json!("facility"), "access.membership.source_recursive", ), @@ -117,7 +117,7 @@ fn membership_boundaries_refuse_unenforced_authority_paths() { let mut value = source("facility"); value["entities"][0]["readPaths"] = json!([{"id":"facilities","through":"membership","to":"facility","route":"facilities"}]); - value["accessProfiles"][1]["grants"][0]["readPaths"] = + value["accessProfiles"][1]["permissions"][0]["readPaths"] = json!([{"path":"facilities","readableFields":["label"]}]); let failure = compile(&value).expect_err("read paths cannot ignore protected target membership"); @@ -127,12 +127,12 @@ fn membership_boundaries_refuse_unenforced_authority_paths() { .any(|diagnostic| diagnostic.code == "access.membership.read_path_target")); let mut action = action_requirements::project(); - action["accessProfiles"][0]["grants"][0]["membershipBoundaries"] = - source("facility")["accessProfiles"][0]["grants"][0]["membershipBoundaries"].clone(); + action["accessProfiles"][0]["permissions"][0]["membershipBoundaries"] = + source("facility")["accessProfiles"][0]["permissions"][0]["membershipBoundaries"].clone(); let failure = compile(&action) .expect_err("an action grant must not silently ignore entity membership rules"); assert!(failure .diagnostics() .iter() - .any(|diagnostic| diagnostic.code == "action.grant.entity_fields_forbidden")); + .any(|diagnostic| diagnostic.code == "action.permission.entity_fields_forbidden")); } diff --git a/crates/registry-breg/tests/native_patterns.rs b/crates/registry-breg/tests/native_patterns.rs index 99d96f3ab3..82c66ad650 100644 --- a/crates/registry-breg/tests/native_patterns.rs +++ b/crates/registry-breg/tests/native_patterns.rs @@ -18,7 +18,7 @@ fn project(pattern: Option<&str>) -> registry_breg::contract::RegistryProject { }, "entities":[{"id":"entry","primaryDataset":"records","route":"entries","mutationMode":"mutable","classification":"internal", "fields":[{"id":"identifier","type":"string","maxLength":100,"classification":"internal"}]}], - "accessProfiles":[{"id":"reader","default":true,"principalClaim":"sub","grants":[{"entity":"entry","operations":["get","list"],"readableFields":["identifier"],"rowBoundaries":[]}]}] + "accessProfiles":[{"id":"reader","default":true,"principalClaim":"sub","permissions":[{"entity":"entry","operations":["get","list"],"readableFields":["identifier"],"rowBoundaries":[]}]}] }); if let Some(pattern) = pattern { value["entities"][0]["fields"][0]["pattern"] = json!(pattern); diff --git a/crates/registry-breg/tests/package_change_plan.rs b/crates/registry-breg/tests/package_change_plan.rs index 95516d361f..822f4c74f9 100644 --- a/crates/registry-breg/tests/package_change_plan.rs +++ b/crates/registry-breg/tests/package_change_plan.rs @@ -824,7 +824,7 @@ fn complete_extension_surface_modules_are_order_independent() { let event_module = parse_module_yaml(br#"{"id":"event-extension","version":"1","extendEntities":[{"entity":"asset","accessProfiles":[{"id":"auditor","principalClaim":"principal","operations":["get","list"],"readableFields":["code","status"],"writableFields":[], "rowBoundaries": []}],"events":[{"id":"asset-created","trigger":"created","projection":["code","status"],"webhook":{"destinationId":"package-change-events"}}]}],"entities":[{"id":"site","primaryDataset":"neutral-registry","route":"sites","mutationMode":"create_only","fields":[{"id":"code","type":"string","maxLength":8,"classification":"internal"}],"accessProfiles":[{"id":"reader","principalClaim":"principal","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"], "rowBoundaries": []}]}]}"#) .expect("event extension parses"); let project_bytes = format!( - r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://package.example.test"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":2,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"id":"neutral-registry-authority","name":"Package Test Publisher"}}}},"publicService":{{"id":"neutral-registry-service","title":"Neutral Registry Catalog"}},"datasets":[{{"id":"neutral-registry","title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}],"dataServices":[{{"id":"neutral-registry-data-service","title":"Neutral Registry Catalog","endpointUrl":"https://package.example.test","servesDatasets":["neutral-registry"]}}]}},"entities":[{{"id":"asset","primaryDataset":"neutral-registry","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}]}}],"accessProfiles":[{{"id":"reader","default":true,"principalClaim":"principal","grants":[{{"rowBoundaries": [], "entity":"asset","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}],"modules":[{{"id":"field-extension","version":"1","digest":"{}"}},{{"id":"event-extension","version":"1","digest":"{}"}}]}}"#, + r#"{{"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject","registry":{{"id":"neutral-registry","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://package.example.test"}},"package":{{"environment":"local","instanceId":"{INSTANCE}","sequence":2,"sourceRevision":"{SOURCE_REVISION}"}},"manifestProjection":{{"accessProfile":"reader","classificationCeiling":"internal","catalog":{{"baseUrl":"https://package.example.test","title":"Neutral Registry Catalog","publisher":{{"id":"neutral-registry-authority","name":"Package Test Publisher"}}}},"publicService":{{"id":"neutral-registry-service","title":"Neutral Registry Catalog"}},"datasets":[{{"id":"neutral-registry","title":"Neutral Registry Dataset","owner":"Package Test Publisher","status":"active"}}],"dataServices":[{{"id":"neutral-registry-data-service","title":"Neutral Registry Catalog","endpointUrl":"https://package.example.test","servesDatasets":["neutral-registry"]}}]}},"entities":[{{"id":"asset","primaryDataset":"neutral-registry","route":"assets","mutationMode":"create_only","fields":[{{"id":"code","type":"string","maxLength":8,"classification":"internal"}}]}}],"accessProfiles":[{{"id":"reader","default":true,"principalClaim":"principal","permissions":[{{"rowBoundaries": [], "entity":"asset","operations":["create","get","list"],"readableFields":["code"],"writableFields":["code"]}}]}}],"modules":[{{"id":"field-extension","version":"1","digest":"{}"}},{{"id":"event-extension","version":"1","digest":"{}"}}]}}"#, module_digest(&field_module), module_digest(&event_module) ); @@ -1790,7 +1790,7 @@ fn project_planner_build_request() -> PackageBuildRequest { }} }}], "accessProfiles":[{{ - "id":"operator","default":true,"principalClaim":"principal","grants":[ + "id":"operator","default":true,"principalClaim":"principal","permissions":[ {{"rowBoundaries": [], "entity":"target","operations":["get","list"],"readableFields":["label"]}}, {{"rowBoundaries": [], "entity":"request","operations":["create","patch","get","list","submit_request","revise_request","cancel_request","approve_request","reject_request","request_revision","apply_request"],"readableFields":["target","label"],"writableFields":["target","label"], "reviewStages":[{{"stage":"review","targets":[{{"rowBoundaries": [], "entity":"target","readableFields":["label"]}}]}}], @@ -2489,7 +2489,7 @@ fn lookup_grant_addition_uses_its_routed_authority_without_storage_ddl() { serde_json::json!([{"id":"by-code","fields":["code"]}]); source["accessProfiles"].as_array_mut().unwrap().push(serde_json::json!({ "id":"source","principalClaim":"registry_principal","requiredScopes":["registry:source:lookup"], - "grants":[{"entity":"record","operations":["lookup"],"readableFields":["code","status"], + "permissions":[{"entity":"record","operations":["lookup"],"readableFields":["code","status"], "lookups":[{"selector":"by-code","valueOrigin":"request"}],"rowBoundaries":[]}]})); let candidate = compile(&source); let changes = compiled_registry_change_set(&previous, &candidate, PRIOR_REVISION); @@ -2502,7 +2502,7 @@ fn lookup_grant_addition_uses_its_routed_authority_without_storage_ddl() { .any(|change| change.code == CompiledRegistryChangeCode::QueryInventoryChanged)); // Changing an existing query projection does not become a grant addition. source["package"]["sequence"] = serde_json::json!(3); - source["accessProfiles"][1]["grants"][0]["readableFields"] = + source["accessProfiles"][1]["permissions"][0]["readableFields"] = serde_json::json!(["code", "status", "label"]); let widened = compile(&source); let changes = compiled_registry_change_set(&candidate, &widened, PRIOR_REVISION); @@ -2526,7 +2526,7 @@ fn cross_entity_read_path_grant_addition_and_removal_are_policy_successors() { "fields":[{"id":"record","type":"reference","target":"record","classification":"internal"}, {"id":"child","type":"reference","target":"child","classification":"internal"}]}) ]); - source["accessProfiles"][0]["grants"].as_array_mut().unwrap().extend([ + source["accessProfiles"][0]["permissions"].as_array_mut().unwrap().extend([ serde_json::json!({"entity":"child","operations":["get"],"readableFields":["code","label"],"rowBoundaries":[]}), serde_json::json!({"entity":"link","operations":["get"],"readableFields":["record","child"],"rowBoundaries":[]}) ]); @@ -2536,7 +2536,7 @@ fn cross_entity_read_path_grant_addition_and_removal_are_policy_successors() { }; let previous = compile(&source); source["package"]["sequence"] = serde_json::json!(2); - source["accessProfiles"][0]["grants"][0]["readPaths"] = + source["accessProfiles"][0]["permissions"][0]["readPaths"] = serde_json::json!([{"path":"children","readableFields":["code"]}]); let granted = compile(&source); let query = granted @@ -2566,7 +2566,7 @@ fn cross_entity_read_path_grant_addition_and_removal_are_policy_successors() { .any(|change| change.code == CompiledRegistryChangeCode::QueryInventoryChanged)); source["package"]["sequence"] = serde_json::json!(3); - source["accessProfiles"][0]["grants"][0]["readPaths"][0]["readableFields"] = + source["accessProfiles"][0]["permissions"][0]["readPaths"][0]["readableFields"] = serde_json::json!(["code", "label"]); let widened = compile(&source); let changes = compiled_registry_change_set(&granted, &widened, PRIOR_REVISION); @@ -2575,7 +2575,7 @@ fn cross_entity_read_path_grant_addition_and_removal_are_policy_successors() { "an existing query projection change still requires review" ); - source["accessProfiles"][0]["grants"][0]["readPaths"] = serde_json::json!([]); + source["accessProfiles"][0]["permissions"][0]["readPaths"] = serde_json::json!([]); let removed = compile(&source); let changes = compiled_registry_change_set(&granted, &removed, PRIOR_REVISION); let plan = change_set_to_applicable_migration_plan(&changes) diff --git a/crates/registry-breg/tests/postgres_action_evidence_targets.rs b/crates/registry-breg/tests/postgres_action_evidence_targets.rs index 79da530a5e..7a2a6ee419 100644 --- a/crates/registry-breg/tests/postgres_action_evidence_targets.rs +++ b/crates/registry-breg/tests/postgres_action_evidence_targets.rs @@ -84,7 +84,7 @@ async fn setup_with_age( document["actions"][0]["handler"]["writes"].as_array_mut().unwrap().push(json!({ "id":"local-register", "target":{"fromField":"local-register"},"operation":"patch","fields":["checked"] })); - document["accessProfiles"][0]["grants"][0]["targets"].as_array_mut().unwrap().push(json!({ + document["accessProfiles"][0]["permissions"][0]["targets"].as_array_mut().unwrap().push(json!({ "entity":"local-register", "rowBoundaries":[{"field":"zone","claim":"zone","operator":"equals"}] })); project = parse_project_yaml(&serde_json::to_vec(&document).unwrap()).unwrap(); diff --git a/crates/registry-breg/tests/postgres_action_handlers.rs b/crates/registry-breg/tests/postgres_action_handlers.rs index 1d685a00dc..c401633260 100644 --- a/crates/registry-breg/tests/postgres_action_handlers.rs +++ b/crates/registry-breg/tests/postgres_action_handlers.rs @@ -136,7 +136,7 @@ async fn setup_with_handler_source( .iter_mut() .find(|profile| profile.id == "person-administrator") .unwrap() - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "person") .unwrap() @@ -175,7 +175,7 @@ async fn setup_with_handler_source( .iter_mut() .find(|profile| profile.id == "person-registrar") .unwrap() - .grants + .permissions .iter_mut() .find(|grant| grant.action.as_deref() == Some("register-person-with-registration")) .unwrap() diff --git a/crates/registry-breg/tests/postgres_anonymous_refusals.rs b/crates/registry-breg/tests/postgres_anonymous_refusals.rs index 5746e2ef30..4ea6f097c3 100644 --- a/crates/registry-breg/tests/postgres_anonymous_refusals.rs +++ b/crates/registry-breg/tests/postgres_anonymous_refusals.rs @@ -59,7 +59,7 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: case rowBoundaries: [] operations: [get, list] @@ -70,7 +70,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [case-management] - grants: + permissions: - entity: case operations: [get, list] readableFields: [label, jurisdiction] diff --git a/crates/registry-breg/tests/postgres_audit_tooling.rs b/crates/registry-breg/tests/postgres_audit_tooling.rs index f3ebba2261..dc771daf86 100644 --- a/crates/registry-breg/tests/postgres_audit_tooling.rs +++ b/crates/registry-breg/tests/postgres_audit_tooling.rs @@ -937,7 +937,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "default":true, "principalClaim":"registry_principal", "requiredPurposes":["operations"], - "grants":[{ + "permissions":[{ "entity":"membership", "operations":["create","get","list","patch"], "readableFields":["person","household"], diff --git a/crates/registry-breg/tests/postgres_batch.rs b/crates/registry-breg/tests/postgres_batch.rs index 1d755c38ed..59ba0bb008 100644 --- a/crates/registry-breg/tests/postgres_batch.rs +++ b/crates/registry-breg/tests/postgres_batch.rs @@ -611,7 +611,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management","case-review"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["create","get","patch","batch"], "readableFields":["jurisdiction","label","locked","quantity"], "writableFields":["jurisdiction","label","secret","quantity"], @@ -620,7 +620,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"batch-creator","principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["create","batch"], "readableFields":["jurisdiction","label","locked","quantity"], "writableFields":["jurisdiction","label","secret","quantity"], @@ -629,7 +629,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"operator-minimal","principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["create","patch","batch"], "readableFields":["label"], "writableFields":["jurisdiction","label","secret","quantity"], diff --git a/crates/registry-breg/tests/postgres_change_requests.rs b/crates/registry-breg/tests/postgres_change_requests.rs index b72862a8bc..a4fdf05949 100644 --- a/crates/registry-breg/tests/postgres_change_requests.rs +++ b/crates/registry-breg/tests/postgres_change_requests.rs @@ -908,7 +908,7 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { }); for profile in &mut project.access_profiles { for grant in profile - .grants + .permissions .iter_mut() .filter(|grant| grant.entity == "correction-request") { @@ -916,7 +916,7 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { for stage in &mut grant.review_stages { stage .targets - .push(registry_breg::contract::ReviewStageTargetGrantSource { + .push(registry_breg::contract::ReviewStageTargetPermissionSource { entity: "correction-request".to_owned(), readable_fields: BTreeSet::from(["evidence".to_owned()]), row_boundaries: vec![], @@ -937,8 +937,8 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { .unwrap() .clone(); hidden.id = "hidden-owner".to_owned(); - hidden.grants[0].readable_fields.remove("evidence"); - hidden.grants[0].writable_fields.remove("evidence"); + hidden.permissions[0].readable_fields.remove("evidence"); + hidden.permissions[0].writable_fields.remove("evidence"); project.access_profiles.push(hidden); let mut other_target = project .access_profiles @@ -948,7 +948,7 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { .clone(); other_target.id = "other-target-reviewer".to_owned(); other_target.default = false; - other_target.grants[0].review_stages[0].targets[0].row_boundaries[0].claim = + other_target.permissions[0].review_stages[0].targets[0].row_boundaries[0].claim = "target_tenant_claim".to_owned(); project.access_profiles.push(other_target); @@ -966,7 +966,7 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { .clone(); profile.id = variant.to_owned(); profile.default = false; - let grant = &mut profile.grants[0]; + let grant = &mut profile.permissions[0]; let stage = &mut grant.review_stages[0]; match variant { "reviewer-no-slot" => stage @@ -983,7 +983,7 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { let target = stage.targets.pop().unwrap(); grant .review_stages - .push(registry_breg::contract::ReviewStageGrantSource { + .push(registry_breg::contract::ReviewStagePermissionSource { stage: "final".to_owned(), targets: vec![target], }); @@ -1010,16 +1010,16 @@ fn attachment_project() -> registry_breg::contract::RegistryProject { .clone(); self_applier.id = "applier-self-reason".to_owned(); self_applier.default = false; - self_applier.grants[0] - .apply_targets - .push(registry_breg::contract::ApplyTargetGrantSource { + self_applier.permissions[0].apply_targets.push( + registry_breg::contract::ApplyTargetPermissionSource { entity: "correction-request".to_owned(), row_boundaries: vec![registry_breg::contract::RowBoundarySource { field: "reason".to_owned(), claim: "self_reason_claim".to_owned(), operator: registry_breg::contract::BoundaryOperator::Equals, }], - }); + }, + ); project.access_profiles.push(self_applier); project @@ -6619,7 +6619,7 @@ fn bounded_snapshot_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -6636,7 +6636,7 @@ fn bounded_snapshot_registry() -> registry_breg::CompiledRegistry { }, { "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], "revisionAccess":true, @@ -6647,7 +6647,7 @@ fn bounded_snapshot_registry() -> registry_breg::CompiledRegistry { }, { "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -6661,7 +6661,7 @@ fn bounded_snapshot_registry() -> registry_breg::CompiledRegistry { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -6718,7 +6718,7 @@ fn long_logical_id_registry() -> registry_breg::CompiledRegistry { } ], "accessProfiles":[{ - "id":"reviewer","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"reviewer","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"placement-correction-request", "operations":["get","list","submit_request","approve_request","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -6791,7 +6791,7 @@ fn registration_registry_with_pattern(pattern: Option<&str>) -> registry_breg::C "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"household", "operations":["create"], "readableFields":["tenant","label","contact-person"], @@ -6802,7 +6802,7 @@ fn registration_registry_with_pattern(pattern: Option<&str>) -> registry_breg::C }, { "id":"operator","default":true,"principalClaim":"registry_principal", - "grants":[ + "permissions":[ { "entity":"registration-request", "operations":["create","get","list","revisions","patch","submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"], @@ -6869,9 +6869,9 @@ fn registration_registry_with_pattern(pattern: Option<&str>) -> registry_breg::C applier.id = "blind-applier".to_owned(); applier.default = false; applier - .grants + .permissions .retain(|grant| grant.entity == "registration-request"); - let grant = &mut applier.grants[0]; + let grant = &mut applier.permissions[0]; grant.operations = BTreeSet::from([ registry_breg::contract::Operation::Get, registry_breg::contract::Operation::ApplyRequest, @@ -6936,7 +6936,7 @@ fn two_stage_project() -> registry_breg::contract::RegistryProject { "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -6953,7 +6953,7 @@ fn two_stage_project() -> registry_breg::contract::RegistryProject { }, { "id":"submitter","principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], "revisionAccess":true, @@ -6964,7 +6964,7 @@ fn two_stage_project() -> registry_breg::contract::RegistryProject { }, { "id":"reviewer","default":true,"principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -6978,7 +6978,7 @@ fn two_stage_project() -> registry_breg::contract::RegistryProject { }, { "id":"final-reviewer","principalClaim":"registry_principal","requiredPurposes":["final"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -6992,7 +6992,7 @@ fn two_stage_project() -> registry_breg::contract::RegistryProject { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -7058,7 +7058,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -7076,7 +7076,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], "revisionAccess":true, @@ -7087,7 +7087,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -7104,7 +7104,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -7182,7 +7182,7 @@ async fn reviewed_native_pattern_failure_rolls_back_prior_effect_and_preserves_f .change_request .as_ref() .unwrap() - .review_grants + .review_permissions .iter() .all(|grant| grant.profile_id != "blind-applier")); let blind_request = get_record( diff --git a/crates/registry-breg/tests/postgres_client_capabilities.rs b/crates/registry-breg/tests/postgres_client_capabilities.rs index 0d3b2cca0a..737155e744 100644 --- a/crates/registry-breg/tests/postgres_client_capabilities.rs +++ b/crates/registry-breg/tests/postgres_client_capabilities.rs @@ -49,7 +49,7 @@ fn registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{"entity":"entry","operations":["create","get","list","patch","batch","tombstone","revisions","snapshot"], + "permissions":[{"entity":"entry","operations":["create","get","list","patch","batch","tombstone","revisions","snapshot"], "readableFields":["code","label","valid-from","valid-to"],"writableFields":["code","label","valid-from","valid-to"], "filterableFields":["code"],"sortableFields":["valid-from"],"allowCount":true,"revisionAccess":true,"rowBoundaries":[] },{"entity":"timestamp-entry","operations":["snapshot"], diff --git a/crates/registry-breg/tests/postgres_client_relationships.rs b/crates/registry-breg/tests/postgres_client_relationships.rs index 6a713d5a0b..ab26f5efdd 100644 --- a/crates/registry-breg/tests/postgres_client_relationships.rs +++ b/crates/registry-breg/tests/postgres_client_relationships.rs @@ -237,7 +237,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredScopes":["registry.read"],"requiredPurposes":["case-management"], - "grants":[ + "permissions":[ { "entity":"household","rowBoundaries":[],"operations":["create","get","list"], "readableFields":["household-code"],"writableFields":["household-code"], diff --git a/crates/registry-breg/tests/postgres_compiled_schema.rs b/crates/registry-breg/tests/postgres_compiled_schema.rs index 02ce0b02e9..5a9184297c 100644 --- a/crates/registry-breg/tests/postgres_compiled_schema.rs +++ b/crates/registry-breg/tests/postgres_compiled_schema.rs @@ -1190,7 +1190,7 @@ fn additive_catalog_registry(variant: AdditiveCatalogVariant) -> registry_breg:: "id": "writer", "default": true, "principalClaim": "registry_principal", - "grants": [{ + "permissions": [{ "entity": "entry", "operations": ["create", "get", "list", "patch"], "readableFields": writable_fields, @@ -1231,7 +1231,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { { "id":"writer","default":true,"principalClaim":"registry_principal", "requiredPurposes":["operations"], - "grants":[ + "permissions":[ { "entity":"entry","operations":["create","get","list","patch"], "readableFields":["tenant","region","label"], @@ -1251,7 +1251,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { { "id":"reviewer","principalClaim":"registry_principal", "requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"entry","operations":["get","list"], "readableFields":["tenant","region","label"], "rowBoundaries":[ @@ -1289,7 +1289,7 @@ fn derived_registry() -> registry_breg::CompiledRegistry { }] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"operator","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"household","operations":["create","get","list"], "readableFields":["tenant","size","child-count","observed-on"], "writableFields":["tenant","size"], diff --git a/crates/registry-breg/tests/postgres_constraint_races.rs b/crates/registry-breg/tests/postgres_constraint_races.rs index e8684f970f..4353b47fc4 100644 --- a/crates/registry-breg/tests/postgres_constraint_races.rs +++ b/crates/registry-breg/tests/postgres_constraint_races.rs @@ -503,7 +503,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }], "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"principal","requiredPurposes":["operations"], - "grants":[{ + "permissions":[{ "entity":"parent","operations":["create","get"],"readableFields":["name"],"writableFields":["name"], "rowBoundaries": [] },{ diff --git a/crates/registry-breg/tests/postgres_data_export.rs b/crates/registry-breg/tests/postgres_data_export.rs index f145902a22..6687f72953 100644 --- a/crates/registry-breg/tests/postgres_data_export.rs +++ b/crates/registry-breg/tests/postgres_data_export.rs @@ -377,7 +377,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":PROFILE, "principalClaim":"registry_principal", "requiredPurposes":["data-export"], - "grants":[{ + "permissions":[{ "entity":"entry", "operations":["create","batch","list"], "readableFields":["code"], diff --git a/crates/registry-breg/tests/postgres_historical.rs b/crates/registry-breg/tests/postgres_historical.rs index 8ab878cc13..72e6e22b91 100644 --- a/crates/registry-breg/tests/postgres_historical.rs +++ b/crates/registry-breg/tests/postgres_historical.rs @@ -986,7 +986,7 @@ fn compiled_registry_for_temporal_type(temporal_type: &str) -> registry_breg::Co "accessProfiles":[{ "id":"historian","default":true,"principalClaim":"registry_principal", "requiredScopes":["registry.read"],"requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"membership","operations":["snapshot"],"readableFields":["household-code","jurisdiction","valid-from","valid-to","case-note","member-count"], "filterableFields":["household-code"],"sortableFields":["household-code"],"allowCount":true, "rowBoundaries":[{"field":"jurisdiction","claim":"jurisdictions","operator":"in"}] @@ -994,7 +994,7 @@ fn compiled_registry_for_temporal_type(temporal_type: &str) -> registry_breg::Co },{ "id":"archivist","principalClaim":"registry_principal", "requiredScopes":["registry.read"],"requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"membership","operations":["snapshot"],"readableFields":["household-code","jurisdiction","valid-from","valid-to","case-note"], "filterableFields":["household-code"],"sortableFields":["household-code"],"allowCount":true, "rowBoundaries": [] diff --git a/crates/registry-breg/tests/postgres_history_commit.rs b/crates/registry-breg/tests/postgres_history_commit.rs index c60d432857..f0e8fbf820 100644 --- a/crates/registry-breg/tests/postgres_history_commit.rs +++ b/crates/registry-breg/tests/postgres_history_commit.rs @@ -713,7 +713,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "default":true, "principalClaim":"registry_principal", "requiredPurposes":["operations"], - "grants":[{ + "permissions":[{ "entity":"membership", "operations":["create","get","list","patch"], "readableFields":["person","household","valid-from","valid-to"], diff --git a/crates/registry-breg/tests/postgres_history_erasure.rs b/crates/registry-breg/tests/postgres_history_erasure.rs index 0dad7b6510..848762a23b 100644 --- a/crates/registry-breg/tests/postgres_history_erasure.rs +++ b/crates/registry-breg/tests/postgres_history_erasure.rs @@ -1250,7 +1250,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "default":true, "principalClaim":"registry_principal", "requiredPurposes":["operations"], - "grants":[{ + "permissions":[{ "entity":"membership", "operations":["create","get","list","patch","snapshot"], "readableFields":["person","household","valid-from","valid-to"], diff --git a/crates/registry-breg/tests/postgres_history_rebaseline.rs b/crates/registry-breg/tests/postgres_history_rebaseline.rs index 43921acbaf..6e346dbd49 100644 --- a/crates/registry-breg/tests/postgres_history_rebaseline.rs +++ b/crates/registry-breg/tests/postgres_history_rebaseline.rs @@ -821,7 +821,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "default":true, "principalClaim":"registry_principal", "requiredPurposes":["operations"], - "grants":[{ + "permissions":[{ "entity":"membership", "operations":["create","get","list","patch","snapshot"], "readableFields":["person","household","valid-from","valid-to"], diff --git a/crates/registry-breg/tests/postgres_immediate_action_activation.rs b/crates/registry-breg/tests/postgres_immediate_action_activation.rs index 01402acdd8..eff4e01ba7 100644 --- a/crates/registry-breg/tests/postgres_immediate_action_activation.rs +++ b/crates/registry-breg/tests/postgres_immediate_action_activation.rs @@ -960,7 +960,7 @@ fn variant_for(registry: &CompiledRegistry) -> Variant { .actions() .actions .first() - .and_then(|action| action.grants.first()) + .and_then(|action| action.permissions.first()) .and_then(|grant| grant.required_scopes.iter().next()) .map(String::as_str) { @@ -1008,7 +1008,7 @@ fn project_bytes(variant: Variant, sequence: u64) -> Vec { "principalClaim":"registry_principal", "requiredScopes":["{scope}"], "requiredPurposes":["contact-registration"], - "grants":[{{ + "permissions":[{{ "action":"register-household-contact", "operations":["invoke"], "targets":[ diff --git a/crates/registry-breg/tests/postgres_immediate_actions.rs b/crates/registry-breg/tests/postgres_immediate_actions.rs index f36ae89166..531bfb0d43 100644 --- a/crates/registry-breg/tests/postgres_immediate_actions.rs +++ b/crates/registry-breg/tests/postgres_immediate_actions.rs @@ -2258,7 +2258,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "principalClaim":"registry_principal", "requiredScopes":["registry:contact:register"], "requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"register-household-contact", "operations":["invoke"], "targets":[ @@ -2303,7 +2303,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "principalClaim":"registry_principal", "requiredScopes":["registry:contact:shadow"], "requiredPurposes":["contact-registration"], - "grants":[{ + "permissions":[{ "action":"register-household-contact", "operations":["invoke"], "targets":[ @@ -2316,7 +2316,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"household-seed-writer", "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"household", "operations":["create"], "readableFields":["household-code","jurisdiction"], diff --git a/crates/registry-breg/tests/postgres_membership_access.rs b/crates/registry-breg/tests/postgres_membership_access.rs index 8dff5ae0d2..1e3bf49ab4 100644 --- a/crates/registry-breg/tests/postgres_membership_access.rs +++ b/crates/registry-breg/tests/postgres_membership_access.rs @@ -496,7 +496,7 @@ async fn real_postgres_membership_successors_match_fresh_install_when_added_chan managed_schema_fingerprint, reconcile_compiled_runtime_acl_for_test, }; let mut base = membership_fixture::source("facility"); - base["accessProfiles"][0]["grants"][0]["membershipBoundaries"] = json!([]); + base["accessProfiles"][0]["permissions"][0]["membershipBoundaries"] = json!([]); let mut previous = membership_fixture::compile(&base).unwrap(); let upgraded = postgres_harness::TestDatabase::create(1).await; let (migration, task) = upgraded.connect_migration().await; @@ -505,7 +505,7 @@ async fn real_postgres_membership_successors_match_fresh_install_when_added_chan .unwrap(); let guarded = membership_fixture::source("facility"); let mut changed = guarded.clone(); - changed["accessProfiles"][0]["grants"][0]["membershipBoundaries"][0]["principalField"] = + changed["accessProfiles"][0]["permissions"][0]["membershipBoundaries"][0]["principalField"] = json!("private-note"); for (stage, source) in [("added", guarded), ("changed", changed), ("removed", base)] { let candidate = membership_fixture::compile(&source).unwrap(); diff --git a/crates/registry-breg/tests/postgres_mutation.rs b/crates/registry-breg/tests/postgres_mutation.rs index d08d694de5..ed1ffccbbb 100644 --- a/crates/registry-breg/tests/postgres_mutation.rs +++ b/crates/registry-breg/tests/postgres_mutation.rs @@ -2312,7 +2312,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management","case-review"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["create","get","list","patch","tombstone"], "readableFields":["jurisdiction","label","note","quantity"], "writableFields":["jurisdiction","label","note","quantity"], @@ -2321,7 +2321,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"review-operator","principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["create","get","list","patch","tombstone"], "readableFields":["jurisdiction","label","note","quantity"], "writableFields":["jurisdiction","label","note","quantity"], @@ -2329,7 +2329,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }] },{ "id":"anonymous-reader","anonymous":true, - "grants":[{ + "permissions":[{ "entity":"widget","operations":["get","list"], "readableFields":["label"], "rowBoundaries": [] @@ -2337,7 +2337,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"label-editor","principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"widget","operations":["get","patch"], "readableFields":["label"], "writableFields":["label"], @@ -2346,7 +2346,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"case-operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"log","operations":["create","get","list"], "readableFields":["jurisdiction","message"], "writableFields":["jurisdiction","message"], diff --git a/crates/registry-breg/tests/postgres_partial_unique.rs b/crates/registry-breg/tests/postgres_partial_unique.rs index 4427dc75b8..a55c2dbcdd 100644 --- a/crates/registry-breg/tests/postgres_partial_unique.rs +++ b/crates/registry-breg/tests/postgres_partial_unique.rs @@ -42,7 +42,7 @@ async fn real_postgres_partial_unique_index_enforces_only_the_closed_predicate() }] }], "accessProfiles":[{ - "id":"operator","default":true,"principalClaim":"principal","grants":[{ + "id":"operator","default":true,"principalClaim":"principal","permissions":[{ "entity":"entry","operations":["get"],"readableFields":["code","status","ended-on"], "rowBoundaries": [] }] diff --git a/crates/registry-breg/tests/postgres_read.rs b/crates/registry-breg/tests/postgres_read.rs index c82f8b3dd9..f98344e945 100644 --- a/crates/registry-breg/tests/postgres_read.rs +++ b/crates/registry-breg/tests/postgres_read.rs @@ -1770,7 +1770,7 @@ fn compiled_registry_with_composite_lookup() -> registry_breg::CompiledRegistry .as_array_mut() .expect("widget has selector profiles") .push(json!({"id": "by-amount-and-label", "fields": ["amount", "label"]})); - source["accessProfiles"][0]["grants"][0]["lookups"] + source["accessProfiles"][0]["permissions"][0]["lookups"] .as_array_mut() .expect("operator has widget lookups") .push(json!({"selector": "by-amount-and-label", "valueOrigin": "request"})); @@ -1863,7 +1863,7 @@ fn registry_source() -> String { "principalClaim":"registry_principal", "requiredScopes":["registry.read"], "requiredPurposes":["case-management","audit-review"], - "grants":[{ + "permissions":[{ "entity":"widget", "operations":["create","get","lookup","list","tombstone"], "readableFields":["label","secret","amount","jurisdiction","ordinal","rank","internal-code"], @@ -1887,7 +1887,7 @@ fn registry_source() -> String { "principalClaim":"registry_principal", "requiredScopes":["registry.read"], "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"widget", "operations":["get","list"], "readableFields":["label","jurisdiction","internal-code"], diff --git a/crates/registry-breg/tests/postgres_read_dependencies.rs b/crates/registry-breg/tests/postgres_read_dependencies.rs index 3151447c2d..e88f1cad18 100644 --- a/crates/registry-breg/tests/postgres_read_dependencies.rs +++ b/crates/registry-breg/tests/postgres_read_dependencies.rs @@ -413,7 +413,7 @@ fn compiled_registry( }], "accessProfiles":[{ "id":"reader","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"entry","operations":["create","get","list","lookup"], "readableFields":["code","active","population"], "writableFields":["code","tenant","amount"], @@ -451,7 +451,7 @@ fn compiled_registry( })) .expect("third independent derived relation"), ); - let grant = &mut project.access_profiles[0].grants[0]; + let grant = &mut project.access_profiles[0].permissions[0]; grant.readable_fields.insert("priority".to_owned()); grant.readable_fields.insert("amount".to_owned()); grant.filterable_fields.insert("code".to_owned()); diff --git a/crates/registry-breg/tests/postgres_request_activation.rs b/crates/registry-breg/tests/postgres_request_activation.rs index 8ea3038781..2d0a1c236e 100644 --- a/crates/registry-breg/tests/postgres_request_activation.rs +++ b/crates/registry-breg/tests/postgres_request_activation.rs @@ -1075,7 +1075,7 @@ fn project_bytes_for_variant(variant: Variant, sequence: u64) -> Vec { "accessProfiles":[ {{ "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{{ + "permissions":[{{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -1093,7 +1093,7 @@ fn project_bytes_for_variant(variant: Variant, sequence: u64) -> Vec { }}, {{ "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{{ + "permissions":[{{ "entity":"correction-request", "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], "revisionAccess":true, @@ -1104,7 +1104,7 @@ fn project_bytes_for_variant(variant: Variant, sequence: u64) -> Vec { }}, {{ "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{{ + "permissions":[{{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"{request_extra_read_write}], @@ -1114,7 +1114,7 @@ fn project_bytes_for_variant(variant: Variant, sequence: u64) -> Vec { }}, {{ "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{{ + "permissions":[{{ "entity":"correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"{request_extra_read_write}], diff --git a/crates/registry-breg/tests/postgres_request_authority.rs b/crates/registry-breg/tests/postgres_request_authority.rs index ce09d450e5..def5e3d0e0 100644 --- a/crates/registry-breg/tests/postgres_request_authority.rs +++ b/crates/registry-breg/tests/postgres_request_authority.rs @@ -1016,7 +1016,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[ { "id":"seed-writer","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-site", "operations":["create"], "readableFields":["tenant","name"], @@ -1032,7 +1032,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-placement", "operations":["get","list"], "readableFields":["tenant","site"], @@ -1042,7 +1042,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"plain-viewer","principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-placement", "operations":["get","list"], "readableFields":["tenant","site"], @@ -1051,7 +1051,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["create","get","list","patch","submit_request","revise_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -1061,7 +1061,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"observer","principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["list"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -1070,7 +1070,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -1087,7 +1087,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], diff --git a/crates/registry-breg/tests/postgres_request_events.rs b/crates/registry-breg/tests/postgres_request_events.rs index ad65412412..0b1c1eaed8 100644 --- a/crates/registry-breg/tests/postgres_request_events.rs +++ b/crates/registry-breg/tests/postgres_request_events.rs @@ -635,7 +635,7 @@ fn lifecycle_project() -> registry_breg::contract::RegistryProject { } }], "accessProfiles":[{ - "id":"steward","principalClaim":"registry_principal","grants":[{ + "id":"steward","principalClaim":"registry_principal","permissions":[{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -649,7 +649,7 @@ fn lifecycle_project() -> registry_breg::contract::RegistryProject { "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] }] },{ - "id":"submitter","default":true,"principalClaim":"registry_principal","grants":[{ + "id":"submitter","default":true,"principalClaim":"registry_principal","permissions":[{ "entity":"placement-correction-request", "operations":["create","get","list","patch","submit_request","revise_request","cancel_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -657,7 +657,7 @@ fn lifecycle_project() -> registry_breg::contract::RegistryProject { "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] }] },{ - "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"],"grants":[{ + "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"],"permissions":[{ "entity":"placement-correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -668,14 +668,14 @@ fn lifecycle_project() -> registry_breg::contract::RegistryProject { }] }] },{ - "id":"service","principalClaim":"registry_principal","requiredPurposes":["webhook"],"grants":[{ + "id":"service","principalClaim":"registry_principal","requiredPurposes":["webhook"],"permissions":[{ "entity":"placement-correction-request", "operations":["get","list"], "readableFields":["tenant","placement","proposed-site","reason"], "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] }] },{ - "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"],"grants":[{ + "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"],"permissions":[{ "entity":"placement-correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], diff --git a/crates/registry-breg/tests/postgres_request_queries.rs b/crates/registry-breg/tests/postgres_request_queries.rs index 4d60749c3a..c65cd0a683 100644 --- a/crates/registry-breg/tests/postgres_request_queries.rs +++ b/crates/registry-breg/tests/postgres_request_queries.rs @@ -650,7 +650,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[ { "id":"steward","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"asset-site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -668,7 +668,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"submitter","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["create","get","list","patch","submit_request","revise_request","cancel_request"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -680,7 +680,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"reviewer","principalClaim":"registry_principal","requiredPurposes":["review"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -695,7 +695,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"applier","principalClaim":"registry_principal","requiredPurposes":["apply"], - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","apply_request"], "readableFields":["tenant","placement","proposed-site","reason"], diff --git a/crates/registry-breg/tests/postgres_request_read_retention.rs b/crates/registry-breg/tests/postgres_request_read_retention.rs index 807c63f84e..c94f29840c 100644 --- a/crates/registry-breg/tests/postgres_request_read_retention.rs +++ b/crates/registry-breg/tests/postgres_request_read_retention.rs @@ -341,6 +341,22 @@ async fn erased_terminal_request_get_keeps_metadata_and_scopes_result_links_to_t assert_eq!(public_item["request"]["proposalVersion"], 1); assert_effect_digests_withheld(public_item); + // Seed protected task metadata through the migration authority after the + // ordinary journey. This proof concerns erasure, not grant admission. + let (migration, migration_task) = database.connect_migration().await; + let retained_request_id = Uuid::parse_str(&request.id).unwrap(); + migration + .execute( + "INSERT INTO registry_internal.registry_request_task_authority + (request_entity_id, request_id, proposal_version, binding) + VALUES ('correction-request', $1, 1, $2)", + &[ + &retained_request_id, + &json!({"subjects":{"person_reference":"private-task-selector"}}), + ], + ) + .await + .expect("protected task detail seeds"); let retention = RequestRetentionOperatorService::new_for_test( registry.as_ref().clone(), identity, @@ -361,6 +377,20 @@ async fn erased_terminal_request_get_keeps_metadata_and_scopes_result_links_to_t .await .expect("terminal request detail erases through the verified operator boundary"); + assert_eq!( + migration + .query_one( + "SELECT count(*) FROM registry_internal.registry_request_task_authority + WHERE request_entity_id = 'correction-request' AND request_id = $1", + &[&retained_request_id], + ) + .await + .unwrap() + .get::<_, i64>(0), + 0, + "proposal erasure removes task selectors" + ); + migration_task.abort(); let erased = get_record( &app, &format!( @@ -1082,7 +1112,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[ { "id":"operator","default":true,"principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"site", "operations":["create","get","list"], "readableFields":["tenant","name"], @@ -1110,7 +1140,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"request-only","principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -1119,7 +1149,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"public-request","anonymous":true, - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["get","list"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -1128,7 +1158,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { }, { "id":"snapshot-reader","principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"correction-request", "operations":["snapshot"], "readableFields":["tenant","placement","proposed-site","reason"], diff --git a/crates/registry-breg/tests/postgres_request_upgrade_retention.rs b/crates/registry-breg/tests/postgres_request_upgrade_retention.rs index 1373cbe4a1..33f0570598 100644 --- a/crates/registry-breg/tests/postgres_request_upgrade_retention.rs +++ b/crates/registry-breg/tests/postgres_request_upgrade_retention.rs @@ -2076,7 +2076,7 @@ fn change_request_project( }} }}{extra_entity}], "accessProfiles":[{{ - "id":"request-reviewer","default":true,"principalClaim":"principal","grants":[{{ + "id":"request-reviewer","default":true,"principalClaim":"principal","permissions":[{{ "entity":"placement-correction-request", "operations":["get","list","submit_request","approve_request","reject_request","request_revision"], "readableFields":["tenant","placement","proposed-site","reason"], @@ -2084,7 +2084,7 @@ fn change_request_project( "reviewStages":[{{"stage":"review","targets":[{{"rowBoundaries": [], "entity":"placement","readableFields":["site","label"]}}]}}] }}] }},{{ - "id":"request-applier","principalClaim":"principal","grants":[{{ + "id":"request-applier","principalClaim":"principal","permissions":[{{ "entity":"placement-correction-request","operations":["get","apply_request"], "readableFields":["tenant","placement"], "rowBoundaries":[{{"field":"tenant","claim":"tenant","operator":"equals"}}], diff --git a/crates/registry-breg/tests/postgres_revision_http.rs b/crates/registry-breg/tests/postgres_revision_http.rs index e5eb722bf4..95dfc4d557 100644 --- a/crates/registry-breg/tests/postgres_revision_http.rs +++ b/crates/registry-breg/tests/postgres_revision_http.rs @@ -885,7 +885,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredScopes":["history.read"],"requiredPurposes":["case-review"], - "grants":[{ + "permissions":[{ "entity":"widget", "operations":["revisions"],"revisionAccess":true, "readableFields":["label"], @@ -894,7 +894,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { },{ "id":"provenance","principalClaim":"registry_principal", "requiredScopes":["history.read"],"requiredPurposes":["case-review"], - "grants":[{ + "permissions":[{ "entity":"widget", "operations":["revisions"],"revisionAccess":true, "readableFields":["label"], diff --git a/crates/registry-breg/tests/postgres_rhai_planner.rs b/crates/registry-breg/tests/postgres_rhai_planner.rs index 9b8250459e..a068ab5809 100644 --- a/crates/registry-breg/tests/postgres_rhai_planner.rs +++ b/crates/registry-breg/tests/postgres_rhai_planner.rs @@ -844,7 +844,7 @@ fn staged_rhai_project() -> Value { "id": "staged-final-reviewer", "principalClaim": "registry_principal", "requiredPurposes": ["person-name-final"], - "grants": [{ + "permissions": [{ "entity": "person-name-change-request", "operations": ["get", "approve_request", "apply_request"], "readableFields": ["person", "given-name", "family-name", "handling"], diff --git a/crates/registry-breg/tests/postgres_spatial_read.rs b/crates/registry-breg/tests/postgres_spatial_read.rs index ccab73b6d3..a4b26c929f 100644 --- a/crates/registry-breg/tests/postgres_spatial_read.rs +++ b/crates/registry-breg/tests/postgres_spatial_read.rs @@ -2448,7 +2448,7 @@ fn plain_geojson_registry_source() -> &'static str { "default":true, "principalClaim":"registry_principal", "requiredScopes":["registry.read"], - "grants":[{ + "permissions":[{ "entity":"plain-site", "operations":["create","get","list"], "readableFields":["code","location"], diff --git a/crates/registry-breg/tests/postgres_spatial_storage.rs b/crates/registry-breg/tests/postgres_spatial_storage.rs index d9651d6f7e..3a5189498f 100644 --- a/crates/registry-breg/tests/postgres_spatial_storage.rs +++ b/crates/registry-breg/tests/postgres_spatial_storage.rs @@ -142,7 +142,7 @@ fn compiled_spatial_registry() -> registry_breg::CompiledRegistry { "geojson":{"geometryField":"location"} }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["create","get","list","patch","tombstone"], "readableFields":["code","location"],"writableFields":["code","location"], "spatialQueries":{"bbox":{"maximumLongitudeSpanDegrees":0.25,"maximumLatitudeSpanDegrees":1.5}}, @@ -177,7 +177,7 @@ fn compiled_spatial_derived_registry() -> registry_breg::CompiledRegistry { }] }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["create","get","list","patch"], "readableFields":["code","location","map-label"], "writableFields":["code","location"], @@ -229,7 +229,7 @@ fn compiled_spatial_cross_entity_derived_registry() -> registry_breg::CompiledRe }] }], "accessProfiles":[{ - "id":"map-reader","default":true,"principalClaim":"principal","grants":[{ + "id":"map-reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"zone","operations":["create","get","list"], "readableFields":["code","label"],"writableFields":["code","label"], "rowBoundaries": [] @@ -377,7 +377,7 @@ fn crs84_point_without_bbox_keeps_non_gis_ddl_and_inventory_stable() { "geojson":{"geometryField":"location"} }], "accessProfiles":[{ - "id":"reader","default":true,"principalClaim":"principal","grants":[{ + "id":"reader","default":true,"principalClaim":"principal","permissions":[{ "entity":"site","operations":["get","list"],"readableFields":["code","location"], "rowBoundaries": [] }] diff --git a/crates/registry-breg/tests/postgres_task_grants.rs b/crates/registry-breg/tests/postgres_task_grants.rs new file mode 100644 index 0000000000..1db8ba26dd --- /dev/null +++ b/crates/registry-breg/tests/postgres_task_grants.rs @@ -0,0 +1,746 @@ +// SPDX-License-Identifier: Apache-2.0 +#![cfg(feature = "postgres-test")] +#[path = "support/postgres_harness.rs"] +#[allow(dead_code)] +mod postgres_harness; +use axum::{ + body::{to_bytes, Body}, + http::{Method, Request, StatusCode}, + Router, +}; +use postgres_harness::TestDatabase; +use registry_breg::api::{ + authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, +}; +use registry_breg::auth::{AuthorityClaimConfig, RegistryAuthenticator}; +use registry_breg::cursor::CursorCodec; +use registry_breg::postgres::{ + initialize_compiled_registry_state_for_test, install_compiled_schema, ExpectedRegistryIdentity, + PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, + RegistryStateTestIdentity, +}; +use registry_breg::task_grant::{TaskGrantBinding, TaskGrantError, TaskGrantStatusChecker}; +use registry_breg::{compile_project, parse_project_json, CompileProfile, CompiledRegistry}; +use registry_platform_audit::AuditProfile; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use tower::ServiceExt; +use uuid::Uuid; +use zeroize::Zeroizing; +const PACKAGE: &str = "task-authority-http"; +const AUDIENCE: &str = "urn:breg:task-test"; +const REVISION: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SOURCE: &str = "https://casework.example"; +const PROJECT: &str = r#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"task-authority-http","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://authoring.example.test"}, + "entities":[ + { + "id":"asset-site", + "primaryDataset":"test-dataset", + "route":"sites", + "mutationMode":"create_only", + "classification":"internal", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"name","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"} + ] + }, + { + "id":"asset-placement", + "primaryDataset":"test-dataset", + "route":"placements", + "mutationMode":"mutable", + "classification":"internal", + "changeControl":{"requiredFor":["patch"]}, + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"site","type":"reference","target":"asset-site","required":true,"classification":"internal"} + ] + }, + { + "id":"correction-request", + "primaryDataset":"test-dataset", + "route":"correction-requests", + "mutationMode":"mutable", + "classification":"internal", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"placement","type":"reference","target":"asset-placement","required":true,"classification":"internal"}, + {"id":"proposed-site","type":"reference","target":"asset-site","required":true,"classification":"internal"}, + {"id":"reason","type":"text","maxLength":1000,"required":true,"classification":"internal"} + ], + "changeRequest":{ + "effects":[{"target":{"fromField":"placement"},"operation":"patch","set":{"site":{"fromField":"proposed-site"}}}], + "review":{"stages":[{"id":"review","approvals":1,"excludeSubmitter":true}]} + } + } + ], + "accessProfiles":[ + { + "id":"steward", + "default":true, + "principalClaim":"sub", + "permissions":[ + { + "entity":"asset-site", + "operations":["create","get","list"], + "readableFields":["tenant","name"], + "writableFields":["tenant","name"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] + }, + { + "entity":"asset-placement", + "operations":["create","get","list","revisions"], + "revisionAccess":true, + "readableFields":["tenant","site"], + "writableFields":["tenant","site"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "requestPresence":[{"requestType":"correction-request","rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}] + } + ] + }, + { + "id":"submitter", + "default":true, + "principalClaim":"sub", + "permissions":[ + { + "entity":"correction-request", + "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], + "revisionAccess":true, + "readableFields":["tenant","placement","proposed-site","reason"], + "writableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] + } + ], + "actorKind":"agent", + "requesterClients":["task-agent"], + "requiredPurposes":["review"], + "taskGrant":{"authority":"casework","sourceIssuer":"https://casework.example"} + }, + { + "id":"reviewer", + "principalClaim":"sub", + "requiredPurposes":["review"], + "permissions":[ + { + "entity":"correction-request", + "operations":["get","list","approve_request","reject_request","request_revision"], + "readableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "reviewStages":[{"stage":"review","targets":[{"entity":"asset-placement","readableFields":["site"],"rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}]}] + } + ] + }, + { + "id":"applier", + "principalClaim":"sub", + "requiredPurposes":["apply"], + "permissions":[ + { + "entity":"correction-request", + "operations":["get","apply_request"], + "readableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "applyTargets":[{"entity":"asset-placement","rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}] + } + ] + } + ] +}"#; + +#[derive(Default)] +struct Status { + bindings: Mutex>, + revoked: Mutex>, + unavailable: Mutex>, + calls: AtomicUsize, + revoke_after_check: Mutex>, +} +impl TaskGrantStatusChecker for Status { + fn check<'a>( + &'a self, + binding: &'a TaskGrantBinding, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!( + self.bindings.lock().unwrap().get(binding.grant_id()), + Some(binding), + "every later human check uses the entire original immutable binding" + ); + if self + .unavailable + .lock() + .unwrap() + .contains(binding.grant_id()) + { + return Err(TaskGrantError::Unavailable); + } + if self.revoked.lock().unwrap().contains(binding.grant_id()) { + Err(TaskGrantError::Refused) + } else { + if self.revoke_after_check.lock().unwrap().as_deref() == Some(binding.grant_id()) { + self.revoked + .lock() + .unwrap() + .insert(binding.grant_id().to_owned()); + } + Ok(()) + } + }) + } +} +impl Status { + fn revoke(&self, id: &str) { + self.revoked.lock().unwrap().insert(id.to_owned()); + } + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} +struct Ready; +impl ReadinessProbe for Ready { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} +async fn install(db: &TestDatabase, registry: &CompiledRegistry) -> ExpectedRegistryIdentity { + let (migration, task) = db.connect_migration().await; + install_compiled_schema(&migration, registry, &db.runtime_role) + .await + .unwrap(); + let identity = initialize_compiled_registry_state_for_test( + &migration, + &db.runtime_role, + registry, + RegistryStateTestIdentity { + package_id: PACKAGE, + environment: "local", + instance_id: "task-instance", + database_id: "task-database", + package_revision: REVISION, + package_sequence: 1, + }, + ) + .await + .unwrap(); + drop(migration); + task.abort(); + identity +} +fn app( + db: &TestDatabase, + registry: Arc, + identity: ExpectedRegistryIdentity, + idp: &MockIdp, + status: Arc, +) -> Router { + let pool = db.runtime_config.build_pool().unwrap(); + let lock = RegistryLockKey::derive(PACKAGE).unwrap(); + let audit = AuditProfile::production_from_secret_bytes(vec![0x9a; 32].into()).unwrap(); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x49; 32]), Duration::from_secs(300)).unwrap(), + ); + let reads = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock, + Duration::from_secs(2), + audit.clone(), + cursors.clone(), + )); + let writes = Arc::new( + PostgresRecordMutationService::new( + pool, + registry.clone(), + identity.clone(), + lock, + Duration::from_secs(2), + audit, + ) + .with_task_status(status), + ); + let keys = Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )); + let mut verifier = oidc_verifier_config(idp.issuer(), vec![AUDIENCE.into()]); + verifier.allowed_clients = vec!["task-agent".into(), "human-client".into()]; + let auth = RegistryAuthenticator::new( + ®istry, + verifier, + keys, + AuthorityClaimConfig::new("sub", Some("registry_purpose".into())), + ) + .unwrap(); + authenticated_router( + Arc::new( + HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.package_revision, + schema_fingerprint: identity.schema_fingerprint, + }, + reads, + Arc::new(Ready), + cursors, + ) + .with_postgres_mutations(writes), + ), + Arc::new(auth), + ) +} +fn human(idp: &MockIdp, subject: &str, purpose: &str) -> String { + idp.mint_token( + json!({"aud":AUDIENCE,"client_id":"human-client","sub":subject,"tenant_claim":"tenant-a","registry_purpose":purpose}), + ) +} +fn agent(idp: &MockIdp, status: &Status) -> (String, String) { + let id = Uuid::new_v4().to_string(); + let expires = chrono::Utc::now().timestamp() + 900; + let project: Value = serde_json::from_str(PROJECT).unwrap(); + let permissions = project["accessProfiles"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "submitter") + .unwrap()["permissions"] + .as_array() + .unwrap(); + let bounds = json!({"type":"breg","permissions":permissions.iter().map(|p|json!({"collection":"correction-requests","operations":p["operations"]})).collect::>()}); + let subjects = json!({"tenant_claim":"tenant-a"}); + let binding:TaskGrantBinding=serde_json::from_value(json!({"grantId":id,"authority":"casework","sourceIssuer":SOURCE,"principal":"agent-subject","client":"task-agent","resource":AUDIENCE,"purpose":"review","bounds":bounds,"subjects":subjects,"expiresAt":expires})).unwrap(); + status.bindings.lock().unwrap().insert(id.clone(), binding); + let token=idp.mint_token(json!({"aud":AUDIENCE,"sub":"agent-subject","client_id":"task-agent","registry_actor_kind":"agent","registry_grant_id":id,"registry_grant_authority":"casework","registry_grant_source_issuer":SOURCE,"registry_grant_client":"task-agent","registry_grant_resource":AUDIENCE,"registry_purpose":"review","registry_grant_exp":expires,"registry_grant_bounds":bounds,"identity":subjects})); + (id, token) +} +struct Response { + status: StatusCode, + body: Value, + etag: String, +} +async fn send( + app: &Router, + method: Method, + uri: &str, + token: &str, + key: Option<&str>, + etag: Option<&str>, + body: Value, +) -> Response { + let mut req = Request::builder() + .method(method.clone()) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + if let Some(key) = key { + req = req.header("idempotency-key", key); + } + if let Some(etag) = etag { + req = req.header("if-match", etag); + } + if method == Method::PATCH { + req = req.header("content-type", "application/json-patch+json"); + } else { + req = req.header("content-type", "application/json"); + } + let raw = if method == Method::GET { + Vec::new() + } else { + serde_json::to_vec(&body).unwrap() + }; + let response = app + .clone() + .oneshot(req.body(Body::from(raw)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let etag = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let body = serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + Response { status, etag, body } +} +async fn create(app: &Router, route: &str, token: &str, key: &str, data: Value) -> Response { + let r = send( + app, + Method::POST, + route, + token, + Some(key), + None, + json!({"data":data}), + ) + .await; + assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); + r +} +fn id(response: &Response) -> String { + response.body["data"]["recordIdentifier"] + .as_str() + .unwrap() + .into() +} +async fn get(app: &Router, id: &str, profile: &str, token: &str) -> Response { + let r = send( + app, + Method::GET, + &format!("/v1/records/correction-requests/{id}?accessProfile={profile}"), + token, + None, + None, + Value::Null, + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + r +} +fn action(r: &Response, operation: &str) -> Value { + r.body["data"]["request"]["actions"] + .as_array() + .unwrap() + .iter() + .find(|a| a["operation"] == operation) + .unwrap() + .clone() +} +async fn perform(app: &Router, action: &Value, token: &str, key: &str) -> Response { + let mut body = if action.get("proposalVersion").is_some() { + json!({"proposalVersion":action["proposalVersion"],"effectDigest":action["effectDigest"]}) + } else { + json!({}) + }; + if action["operation"] == "reject_request" { + body["reason"] = json!("The task was revoked."); + } + send( + app, + Method::POST, + action["href"].as_str().unwrap(), + token, + Some(key), + action["ifMatch"].as_str(), + body, + ) + .await +} +async fn counts(db: &TestDatabase) -> Vec { + let mut counts = Vec::new(); + for table in [ + "registry_revisions", + "registry_idempotency", + "registry_request_proposals", + "registry_request_decisions", + "registry_request_applications", + "registry_request_results", + "registry_request_task_authority", + ] { + counts.push( + db.admin + .query_one( + &format!("SELECT count(*) FROM registry_internal.{table}"), + &[], + ) + .await + .unwrap() + .get(0), + ); + } + counts +} +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn task_http_to_postgres_preserves_original_authority_and_completed_receipts() { + let db = TestDatabase::create(8).await; + let registry = Arc::new( + compile_project( + &parse_project_json(PROJECT.as_bytes()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap(), + ); + let identity = install(&db, ®istry).await; + let idp = MockIdp::start().await; + let status = Arc::new(Status::default()); + let app = app(&db, registry.clone(), identity, &idp, status.clone()); + let steward = human(&idp, "steward", "maintain"); + let reviewer = human(&idp, "reviewer", "review"); + let applier = human(&idp, "applier", "apply"); + let old = create( + &app, + "/v1/records/sites?accessProfile=steward", + &steward, + "old-site", + json!({"tenant":"tenant-a","name":"old"}), + ) + .await; + let new = create( + &app, + "/v1/records/sites?accessProfile=steward", + &steward, + "new-site", + json!({"tenant":"tenant-a","name":"new"}), + ) + .await; + let target = create( + &app, + "/v1/records/placements?accessProfile=steward", + &steward, + "placement", + json!({"tenant":"tenant-a","site":id(&old)}), + ) + .await; + for phase in [ + "draft", + "unavailable", + "retry", + "submitted", + "approved", + "applied", + ] { + let (grant, token) = agent(&idp, &status); + let create_key = format!("{phase}-create"); + let before_calls = status.calls(); + let draft=create(&app,"/v1/records/correction-requests?accessProfile=submitter",&token,&create_key,json!({"tenant":"tenant-a","placement":id(&target),"proposedSite":id(&new),"reason":"synthetic correction"})).await; + assert_eq!( + status.calls(), + before_calls + 1, + "HTTP grant must reach the SQL coordinator" + ); + let record = id(&draft); + let read = get(&app, &record, "submitter", &token).await; + let submit = action(&read, "submit_request"); + let (other_grant, other_token) = agent(&idp, &status); + let same_read = get(&app, &record, "submitter", &other_token).await; + assert_eq!( + read.etag, same_read.etag, + "grant IDs do not alter read ETags" + ); + let conflict=send(&app,Method::POST,"/v1/records/correction-requests?accessProfile=submitter",&other_token,Some(&create_key),None,json!({"data":{"tenant":"tenant-a","placement":id(&target),"proposedSite":id(&new),"reason":"synthetic correction"}})).await; + assert_eq!( + conflict.status, + StatusCode::CONFLICT, + "different immutable grant cannot reuse key" + ); + status.revoke(&other_grant); + if phase == "draft" { + status.revoke(&grant); + let before = counts(&db).await; + let patch = send( + &app, + Method::PATCH, + &format!("/v1/records/correction-requests/{record}?accessProfile=submitter"), + &token, + Some("revoked-patch"), + Some(&read.etag), + json!([{"op":"replace","path":"/data/reason","value":"must not commit"}]), + ) + .await; + assert_eq!( + patch.status, + StatusCode::PRECONDITION_FAILED, + "{}", + patch.body + ); + assert_eq!( + perform(&app, &submit, &token, "revoked-submit") + .await + .status, + StatusCode::PRECONDITION_FAILED + ); + assert_eq!(counts(&db).await, before); + let call_count = status.calls(); + let recovered=create(&app,"/v1/records/correction-requests?accessProfile=submitter",&token,&create_key,json!({"tenant":"tenant-a","placement":id(&target),"proposedSite":id(&new),"reason":"synthetic correction"})).await; + assert_eq!(recovered.body, draft.body); + assert_eq!(status.calls(), call_count); + assert_eq!(counts(&db).await, before); + continue; + } + if phase == "unavailable" { + status.unavailable.lock().unwrap().insert(grant.clone()); + let before = counts(&db).await; + assert_eq!( + perform(&app, &submit, &token, "unavailable-submit") + .await + .status, + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(counts(&db).await, before); + continue; + } + if phase == "retry" { + let table = ®istry.entities()["correction-request"].physical_table; + let role = db.runtime_role.as_str(); + db.admin.batch_execute(&format!(r#" + CREATE SEQUENCE registry_internal.task_retry_sequence; + GRANT USAGE, SELECT ON SEQUENCE registry_internal.task_retry_sequence TO "{role}"; + CREATE FUNCTION registry_internal.task_retry_once() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF nextval('registry_internal.task_retry_sequence')=1 THEN + RAISE EXCEPTION 'synthetic serialization abort' USING ERRCODE='40001'; + END IF; + RETURN NEW; + END $$; + CREATE TRIGGER task_retry_once BEFORE UPDATE ON registry_data."{table}" + FOR EACH ROW EXECUTE FUNCTION registry_internal.task_retry_once(); + "#)).await.unwrap(); + *status.revoke_after_check.lock().unwrap() = Some(grant.clone()); + let before = counts(&db).await; + let calls = status.calls(); + let refused = perform(&app, &submit, &token, "serialization-retry").await; + assert_eq!( + refused.status, + StatusCode::PRECONDITION_FAILED, + "{}", + refused.body + ); + assert_eq!( + status.calls(), + calls + 2, + "an aborted SQL transaction must acquire fresh status on retry" + ); + assert_eq!( + counts(&db).await, + before, + "the aborted first attempt leaves no mutation or proposal state" + ); + db.admin + .batch_execute(&format!( + "DROP TRIGGER task_retry_once ON registry_data.\"{table}\";" + )) + .await + .unwrap(); + continue; + } + let submit_key = format!("{phase}-submit"); + let submitted = perform(&app, &submit, &token, &submit_key).await; + assert_eq!(submitted.status, StatusCode::OK, "{}", submitted.body); + let stored:Value=db.admin.query_one("SELECT binding FROM registry_internal.registry_request_task_authority WHERE request_id=$1",&[&Uuid::parse_str(&record).unwrap()]).await.unwrap().get(0); + assert_eq!(stored["grantId"], grant); + assert_eq!(stored["principal"], "agent-subject"); + let review = action( + &get(&app, &record, "reviewer", &reviewer).await, + "approve_request", + ); + if phase == "submitted" { + status.revoke(&grant); + let before = counts(&db).await; + assert_eq!( + perform(&app, &review, &reviewer, "revoked-approve") + .await + .status, + StatusCode::PRECONDITION_FAILED + ); + assert_eq!(counts(&db).await, before); + let call_count = status.calls(); + assert_eq!( + perform(&app, &submit, &token, &submit_key).await.status, + StatusCode::OK + ); + assert_eq!( + status.calls(), + call_count, + "completed receipt does not reacquire mutation authority" + ); + let reject = action( + &get(&app, &record, "reviewer", &reviewer).await, + "reject_request", + ); + assert_eq!( + perform(&app, &reject, &reviewer, "human-reject") + .await + .status, + StatusCode::OK + ); + assert_eq!( + status.calls(), + call_count, + "reviewer rejection does not depend on revoked task" + ); + continue; + } + let review_key = format!("{phase}-approve"); + assert_eq!( + perform(&app, &review, &reviewer, &review_key).await.status, + StatusCode::OK + ); + let apply = action( + &get(&app, &record, "applier", &applier).await, + "apply_request", + ); + if phase == "approved" { + status.revoke(&grant); + let before = counts(&db).await; + assert_eq!( + perform(&app, &apply, &applier, "revoked-apply") + .await + .status, + StatusCode::PRECONDITION_FAILED + ); + assert_eq!(counts(&db).await, before); + continue; + } + let receipt = perform(&app, &apply, &applier, "apply").await; + assert_eq!(receipt.status, StatusCode::OK, "{}", receipt.body); + status.revoke(&grant); + let before = counts(&db).await; + let call_count = status.calls(); + let replay = perform(&app, &apply, &applier, "apply").await; + assert_eq!(replay.status, StatusCode::OK); + assert_eq!(replay.body, receipt.body); + assert_eq!(counts(&db).await, before); + assert_eq!(status.calls(), call_count); + let recovered = perform(&app, &apply, &applier, "apply-new-recovery-key").await; + assert_eq!(recovered.status, StatusCode::OK); + assert_eq!(recovered.body, receipt.body); + assert_eq!(status.calls(), call_count); + let mut receipt_only = before.clone(); + receipt_only[1] += 1; + assert_eq!( + counts(&db).await, + receipt_only, + "new recovery key may retain its receipt but cannot apply again" + ); + let before = receipt_only; + let changed = send( + &app, + Method::POST, + apply["href"].as_str().unwrap(), + &applier, + Some("apply"), + apply["ifMatch"].as_str(), + json!({"proposalVersion":2,"effectDigest":apply["effectDigest"]}), + ) + .await; + assert_eq!(changed.status, StatusCode::CONFLICT); + assert_eq!(counts(&db).await, before); + let hidden=idp.mint_token(json!({"aud":AUDIENCE,"client_id":"human-client","sub":"applier","tenant_claim":"tenant-b","registry_purpose":"apply"})); + let refusal = perform(&app, &apply, &hidden, "apply").await; + assert!( + !refusal.status.is_success(), + "a completed receipt still requires current disclosure" + ); + assert_eq!(counts(&db).await, before); + assert_eq!(status.calls(), call_count); + } + db.cleanup().await; +} diff --git a/crates/registry-breg/tests/postgres_temporal_corrections.rs b/crates/registry-breg/tests/postgres_temporal_corrections.rs index 4ba1687a56..5b9daa4091 100644 --- a/crates/registry-breg/tests/postgres_temporal_corrections.rs +++ b/crates/registry-breg/tests/postgres_temporal_corrections.rs @@ -294,7 +294,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["history-maintenance"],"requiredScopes":["history-maintain"], - "grants":[{ + "permissions":[{ "entity":"membership","operations":["create","get","patch","batch"], "readableFields":["subject","group","valid-from","valid-to"], "writableFields":["subject","group","valid-from","valid-to","source-reference"], diff --git a/crates/registry-breg/tests/postgres_tombstone_revision.rs b/crates/registry-breg/tests/postgres_tombstone_revision.rs index 15d1d1b00e..c31aa65865 100644 --- a/crates/registry-breg/tests/postgres_tombstone_revision.rs +++ b/crates/registry-breg/tests/postgres_tombstone_revision.rs @@ -567,7 +567,7 @@ fn compiled_registry(tombstone: bool) -> registry_breg::CompiledRegistry { "accessProfiles":[{{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{{ + "permissions":[{{ "entity":"widget","operations":[{operations}], "readableFields":["jurisdiction","label","quantity"], "writableFields":["jurisdiction","label","quantity"], diff --git a/crates/registry-breg/tests/postgres_webhook_delivery.rs b/crates/registry-breg/tests/postgres_webhook_delivery.rs index 662e8d30a8..68b57bee25 100644 --- a/crates/registry-breg/tests/postgres_webhook_delivery.rs +++ b/crates/registry-breg/tests/postgres_webhook_delivery.rs @@ -1606,7 +1606,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"case","operations":["create","get","list"], "readableFields":["jurisdiction","label","restricted_note"], "writableFields":["jurisdiction","label","restricted_note"], diff --git a/crates/registry-breg/tests/postgres_webhook_outbox.rs b/crates/registry-breg/tests/postgres_webhook_outbox.rs index ab916dd89f..9225a1899a 100644 --- a/crates/registry-breg/tests/postgres_webhook_outbox.rs +++ b/crates/registry-breg/tests/postgres_webhook_outbox.rs @@ -640,7 +640,7 @@ fn compiled_registry() -> registry_breg::CompiledRegistry { "accessProfiles":[{ "id":"operator","default":true,"principalClaim":"registry_principal", "requiredPurposes":["case-management"], - "grants":[{ + "permissions":[{ "entity":"case","operations":["create","patch","get","list"], "readableFields":["jurisdiction","label","restricted_note"], "writableFields":["jurisdiction","label","restricted_note"], diff --git a/crates/registry-breg/tests/request_visibility_contract.rs b/crates/registry-breg/tests/request_visibility_contract.rs index e8f67c6822..d5a0393c89 100644 --- a/crates/registry-breg/tests/request_visibility_contract.rs +++ b/crates/registry-breg/tests/request_visibility_contract.rs @@ -38,7 +38,7 @@ fn owner_request_visibility_is_rejected_outside_authenticated_request_reads() { .find(|profile| profile.id == "site-planner") .expect("site planner exists"); site_planner - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "asset-item") .expect("asset item grant exists") @@ -57,7 +57,7 @@ fn owner_request_visibility_is_rejected_outside_authenticated_request_reads() { .find(|profile| profile.id == "correction-submitter") .expect("correction submitter exists"); let grant = submitter - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "placement-correction-request") .expect("request grant exists"); @@ -87,7 +87,7 @@ fn request_reason_read_fields_default_to_reason_and_allow_an_explicit_empty_proj .iter_mut() .find(|profile| profile.id == "correction-submitter") .expect("submitter") - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "placement-correction-request") .expect("request grant"); @@ -108,7 +108,7 @@ fn request_reason_read_fields_default_to_reason_and_allow_an_explicit_empty_proj #[test] fn request_reason_read_fields_reject_unknown_metadata_fields() { let mut project = serde_json::to_value(acceptance_project()).expect("project serializes"); - project["accessProfiles"][0]["grants"][0]["readableRequestFields"] = + project["accessProfiles"][0]["permissions"][0]["readableRequestFields"] = serde_json::json!(["actor"]); assert!(registry_breg::contract::parse_project_json( &serde_json::to_vec(&project).expect("JSON") @@ -124,7 +124,7 @@ fn request_reason_permissions_refuse_non_request_entity_overrides() { .iter_mut() .find(|profile| profile.id == "site-planner") .expect("site planner") - .grants + .permissions .iter_mut() .find(|grant| grant.entity == "asset-item") .expect("ordinary entity grant"); diff --git a/crates/registry-breg/tests/schema_fingerprint_rehearsal.rs b/crates/registry-breg/tests/schema_fingerprint_rehearsal.rs index de09f42f4d..ae89eeddd3 100644 --- a/crates/registry-breg/tests/schema_fingerprint_rehearsal.rs +++ b/crates/registry-breg/tests/schema_fingerprint_rehearsal.rs @@ -424,7 +424,7 @@ fn project_bytes(environment: &str, instance_id: &str, source_revision: &str) -> "accessProfiles": [{{ "id": "reader", "principalClaim": "principal", - "grants": [{{ + "permissions": [{{ "rowBoundaries": [], "entity": "case", "operations": ["get", "list"], "readableFields": ["code"] diff --git a/crates/registry-breg/tests/starter_projects.rs b/crates/registry-breg/tests/starter_projects.rs index d16df6f902..e3abe186fe 100644 --- a/crates/registry-breg/tests/starter_projects.rs +++ b/crates/registry-breg/tests/starter_projects.rs @@ -40,7 +40,7 @@ fn published_starters_compile_with_reviewed_update_policy() { .iter_mut() .find(|profile| profile["id"] == "editor") .unwrap(); - editor["grants"][0]["operations"] + editor["permissions"][0]["operations"] .as_array_mut() .unwrap() .push("patch".into()); diff --git a/crates/registry-breg/tests/startup_http.rs b/crates/registry-breg/tests/startup_http.rs index b99b8a26d3..02897e359f 100644 --- a/crates/registry-breg/tests/startup_http.rs +++ b/crates/registry-breg/tests/startup_http.rs @@ -70,7 +70,7 @@ accessProfiles: - id: public default: true anonymous: true - grants: + permissions: - entity: public-record rowBoundaries: [] operations: [list] diff --git a/crates/registry-breg/tests/support/action_requirements.rs b/crates/registry-breg/tests/support/action_requirements.rs index 845d401c62..4ea82d2444 100644 --- a/crates/registry-breg/tests/support/action_requirements.rs +++ b/crates/registry-breg/tests/support/action_requirements.rs @@ -29,7 +29,7 @@ pub fn project() -> Value { "effects": [{"id": "child", "target": {"entity": "child"}, "operation": "create", "set": {"parent": {"fromField": "parent"}, "label": {"fromField": "label"}}}] }], "accessProfiles": [{"id": "registrar", "default": true, "principalClaim": "registry_principal", "requiredScopes": ["registry:register", "registry:parent:process"], - "grants": [{"action": "register-child", "operations": ["invoke"], "targets": [ + "permissions": [{"action": "register-child", "operations": ["invoke"], "targets": [ {"entity": "parent", "rowBoundaries": [{"field": "zone", "claim": "zone", "operator": "equals"}]}, {"entity": "child", "rowBoundaries": []} ], "results": ["child"]}] }] diff --git a/crates/registry-breg/tests/support/immediate_action_review_regressions.rs b/crates/registry-breg/tests/support/immediate_action_review_regressions.rs index 36a7e16713..39f28c27b1 100644 --- a/crates/registry-breg/tests/support/immediate_action_review_regressions.rs +++ b/crates/registry-breg/tests/support/immediate_action_review_regressions.rs @@ -619,7 +619,7 @@ fn compiled_lock_order_action_registry() -> registry_breg::CompiledRegistry { "principalClaim": "registry_principal", "requiredScopes": ["registry:contact:register"], "requiredPurposes": ["contact-registration"], - "grants": [{ + "permissions": [{ "action": "cross-link-lock-record", "operations": ["invoke"], "targets": [{ @@ -989,7 +989,7 @@ fn compiled_wide_action_registry() -> registry_breg::CompiledRegistry { "principalClaim": "registry_principal", "requiredScopes": ["registry:contact:register"], "requiredPurposes": ["contact-registration"], - "grants": [{ + "permissions": [{ "action": "patch-wide-flags", "operations": ["invoke"], "targets": [{ diff --git a/crates/registry-breg/tests/support/membership_fixture.rs b/crates/registry-breg/tests/support/membership_fixture.rs index f06d1f0ca6..3bcde04341 100644 --- a/crates/registry-breg/tests/support/membership_fixture.rs +++ b/crates/registry-breg/tests/support/membership_fixture.rs @@ -18,8 +18,8 @@ pub fn source(root: &str) -> Value { } else { format!("{root}s") }); - value["accessProfiles"][0]["grants"][0]["entity"] = json!(root); - value["accessProfiles"][1]["grants"][2]["entity"] = json!(root); + value["accessProfiles"][0]["permissions"][0]["entity"] = json!(root); + value["accessProfiles"][1]["permissions"][2]["entity"] = json!(root); value } diff --git a/crates/registry-breg/tests/support/reviewer_reasons.rs b/crates/registry-breg/tests/support/reviewer_reasons.rs index 2528a21535..6dfd429584 100644 --- a/crates/registry-breg/tests/support/reviewer_reasons.rs +++ b/crates/registry-breg/tests/support/reviewer_reasons.rs @@ -438,7 +438,7 @@ fn reason_registry() -> registry_breg::CompiledRegistry { .unwrap() .clone(); hidden["id"] = json!("reason-hidden"); - hidden["grants"][0]["readableRequestFields"] = json!([]); + hidden["permissions"][0]["readableRequestFields"] = json!([]); profiles.push(hidden); let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).expect("reason fixture parses"); diff --git a/crates/registry-breg/tests/support/submitter_targets.rs b/crates/registry-breg/tests/support/submitter_targets.rs index 367c8d08f0..4d7a158070 100644 --- a/crates/registry-breg/tests/support/submitter_targets.rs +++ b/crates/registry-breg/tests/support/submitter_targets.rs @@ -342,7 +342,7 @@ async fn review_snapshots_require_current_target_authority() { .iter_mut() .find(|profile| profile["id"] == "reviewer") .unwrap(); - reviewer["grants"][1]["reviewStages"][0]["targets"][0]["rowBoundaries"] = + reviewer["permissions"][1]["reviewStages"][0]["targets"][0]["rowBoundaries"] = json!([{"field":"person-reference","claim":"person_reference","operator":"equals"}]); let project = parse_project_json(&serde_json::to_vec(&source).unwrap()).unwrap(); let registry = Arc::new(compile_project(&project, &[], CompileProfile::Authoring).unwrap()); diff --git a/crates/registry-bregctl/Cargo.toml b/crates/registry-bregctl/Cargo.toml index 7715df493d..d1795c8cde 100644 --- a/crates/registry-bregctl/Cargo.toml +++ b/crates/registry-bregctl/Cargo.toml @@ -31,6 +31,7 @@ p256.workspace = true rcgen.workspace = true registry-platform-canonical-json.workspace = true registry-platform-httputil.workspace = true +registry-thunderid-tooling.workspace = true registry-platform-buildinfo.workspace = true registry-breg = { workspace = true, features = ["runtime", "tooling"] } registry-breg-client.workspace = true @@ -49,6 +50,7 @@ time.workspace = true tokio = { workspace = true, features = ["rt"] } uuid = { workspace = true, features = ["serde"] } zeroize.workspace = true +registry-platform-crypto.workspace = true +url.workspace = true [dev-dependencies] -registry-platform-crypto.workspace = true diff --git a/crates/registry-bregctl/src/data_lifecycle.rs b/crates/registry-bregctl/src/data_lifecycle.rs index a93ee7bc6c..c18b0f7558 100644 --- a/crates/registry-bregctl/src/data_lifecycle.rs +++ b/crates/registry-bregctl/src/data_lifecycle.rs @@ -1176,7 +1176,7 @@ mod tests { "accessProfiles": [{ "id": PROFILE, "principalClaim": "principal", - "grants": [{ + "permissions": [{ "entity": ENTITY, "operations": ["create", "batch", "list"], "readableFields": ["code"], diff --git a/crates/registry-bregctl/src/dev/config.rs b/crates/registry-bregctl/src/dev/config.rs index 0dfa84fa53..63f9799e9f 100644 --- a/crates/registry-bregctl/src/dev/config.rs +++ b/crates/registry-bregctl/src/dev/config.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! Authored local teaching identities and generated private service bindings. -use super::{private, State, DATABASE_ID, MIGRATION_ROLE, RUNTIME_ROLE}; +use super::{private, State, DATABASE_ID, MAX_BYTES, MIGRATION_ROLE, RUNTIME_ROLE}; use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use p256::ecdsa::SigningKey; @@ -166,41 +166,27 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( "credentials", "secrets", "tls", - "mint", + "issuer", "logs", "empty-package", "database", ] { private::directory(&root.join(directory))?; } - private::directory(&root.join("mint/clients"))?; - private::directory(&root.join("mint/audit"))?; - let mint_public = keypair(&root.join("credentials/issuer"))?; - let mint_public_filename = format!( - "{}.jwk.json", - mint_public["kid"] - .as_str() - .context("generated issuer key ID missing")? - ); - private::create( - &root.join("credentials/issuer").join(&mint_public_filename), - &serde_json::to_vec(&mint_public)?, - )?; for client in &clients.clients { let directory = root.join("credentials").join(&client.id); - let public = keypair(&directory)?; + keypair(&directory)?; private::create(&directory.join("client-id"), client.id.as_bytes())?; - write_yaml( - &root - .join("mint/clients") - .join(format!("{}.yaml", client.id)), - &json!({ - "clientId":client.id,"principal":format!("urn:breg:dev:{}",client.id), - "authorization":{"scopes":client.scopes,"claims":client.claims},"keys":[public] - }), - )?; } - for filename in ["audit-key", "cursor-key", "mint-audit-key"] { + // The dev session's issuer is the pinned upstream ThunderID container, + // rendered and provisioned through the shared tooling crate from these + // same authored declarations. BREG keeps its database, seeding, retained + // state, private outputs, and ownership behavior; only the token issuer + // changes hands. + let description = issuer_description(state, clients, root)?; + registry_thunderid_tooling::render::render(&description) + .map_err(|error| anyhow::anyhow!("the dev issuer registration was refused: {error}"))?; + for filename in ["audit-key", "cursor-key"] { let mut bytes = Zeroizing::new([0u8; 32]); getrandom::fill(bytes.as_mut()).context("cannot generate local secret")?; let encoded = Zeroizing::new(URL_SAFE_NO_PAD.encode(bytes.as_ref())); @@ -209,10 +195,6 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( if state.webhook_port.is_some() { webhook_secret(root)?; } - private::create( - &root.join("secrets/mint-jwks"), - &serde_json::to_vec(&json!({"keys":[mint_public]}))?, - )?; let password = Zeroizing::new(uuid::Uuid::new_v4().simple().to_string()); private::create( &root.join("database/postgres.env"), @@ -278,27 +260,6 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( )?; private::create(&root.join("database/pg_hba.conf"), b"local all all trust\nhostnossl all all 0.0.0.0/0 reject\nhostnossl all all ::/0 reject\nhostssl all all 0.0.0.0/0 scram-sha-256\nhostssl all all ::/0 scram-sha-256\n")?; private::create(&root.join("trust-anchor.json"), b"{}")?; - let final_root = state.root(); - let mint_origin = state.mint_origin(); - write_yaml( - &root.join("mint/mint.yaml"), - &json!({ - "version":1,"validationMode":"supervised-local-development","issuer":mint_origin, - "listener":{"address":"127.0.0.1","port":state.mint_port}, - "signing":{"algorithm":"ES256","activePublicJwkFile":final_root.join("credentials/issuer").join(mint_public_filename),"publishedPublicJwkFiles":[],"revokedKeyIds":[]}, - "signer":{"kind":"local-jwk","privateKeyRef":"secret:file/assertion-key.jwk"}, - "secretProviders":{"file":{"root":final_root.join("credentials/issuer")}}, - "audit":{"path":"audit/mint.jsonl","maximumFileBytes":10485760,"hashKeyRef":"secret:file/mint-audit-key","hashKeyVersion":1}, - "accessTokens":{"audiences":[state.audience()],"lifetimeSeconds":300}, - "clientAssertion":{"audience":format!("{mint_origin}/token"),"maximumLifetimeSeconds":120,"algorithms":["ES256"]}, - "clients":{"directory":"clients"} - }), - )?; - // One secret root serves Mint signing and audit; no cross-directory secret references. - private::create( - &root.join("credentials/issuer/mint-audit-key"), - &private::read(&root.join("secrets/mint-audit-key"), 64)?, - )?; runtime( root, state, @@ -309,6 +270,105 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( Ok(()) } +/// The dev session's issuer description: one resource server whose +/// identifier is BREG's exact access-token audience, one role per authored +/// client carrying that client's scopes, and one machine agent per client +/// whose static attributes are the authored claims. Derived from the +/// reviewed client declarations only; nothing here reads the registry +/// project's business model. +pub(super) fn issuer_description( + state: &State, + clients: &Clients, + root: &Path, +) -> Result { + use registry_thunderid_tooling::{ + description::SessionIdentity, + local::{local_description, LocalClient}, + }; + let local_clients = clients + .clients + .iter() + .map(|client| { + let claims = client + .claims + .iter() + .map(|(name, value)| { + let value = value.as_str().with_context(|| { + format!("claim {name:?} must be a string to ride a machine token") + })?; + Ok((name.clone(), value.to_owned())) + }) + .collect::>>()?; + let directory = root.join("credentials").join(&client.id); + let public: Value = + serde_json::from_slice(&private::read(&directory.join("public.jwk"), 4096)?)?; + Ok(LocalClient { + client_id: client.id.clone(), + public_jwks: serde_json::to_string(&json!({"keys":[public]}))?, + claims, + scopes: client.scopes.clone(), + allow_human_fixture: false, + }) + }) + .collect::>>()?; + local_description( + SessionIdentity { + label: format!("breg-dev-{}", state.instance_id), + id: state.instance_id.clone(), + }, + state.issuer_port, + root.join("issuer"), + state.audience(), + local_clients, + ) + .map_err(Into::into) +} + +/// Re-render an explicitly prepared, stopped-session successor in a separate +/// private tree, then publish only this session's native issuer documents. +/// The owning source-transition journal makes an interrupted publication +/// repeatable. Existing client keys and unrelated issuer database state stay +/// untouched. +pub(super) fn refresh_issuer_registration(state: &State, clients: &Clients) -> Result<()> { + fn publish_tree(source: &Path, destination: &Path) -> Result<()> { + private::directory(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source = entry.path(); + let destination = destination.join(entry.file_name()); + if entry.file_type()?.is_dir() { + publish_tree(&source, &destination)?; + } else { + let bytes = private::read(&source, MAX_BYTES)?; + private::replace(&destination, &bytes)?; + } + } + Ok(()) + } + + let root = state.root(); + let staging = root.join(format!(".issuer-render-{}", uuid::Uuid::new_v4())); + private::directory(&staging)?; + let result: Result<()> = (|| { + let mut description = issuer_description(state, clients, &root)?; + description.state_root = staging.clone(); + registry_thunderid_tooling::render::render(&description).map_err(|error| { + anyhow::anyhow!("the successor issuer registration was refused: {error}") + })?; + private::validate_tree(&staging)?; + for directory in ["resources", "registry-schema"] { + publish_tree( + &staging.join(directory), + &root.join("issuer").join(directory), + )?; + } + Ok(()) + })(); + let cleanup = fs::remove_dir_all(&staging); + result?; + cleanup.context("cannot remove the private issuer rendering stage") +} + pub(super) fn runtime( root: &Path, state: &State, @@ -338,7 +398,7 @@ pub(super) fn runtime( "secretProviders":{"file":{"root":final_root.join("secrets")}}, "database":{"runtimeUrlRef":format!("secret:file/{prefix}runtime-database-url"),"migrationUrlRef":format!("secret:file/{prefix}migration-database-url"),"pool":{"maxSize":4},"roles":{"migration":MIGRATION_ROLE,"runtime":RUNTIME_ROLE}}, "package":{"root":final_root.join(if test {"empty-package"}else{"build/package"}),"trustAnchorPath":final_root.join("trust-anchor.json"),"compilerSourceRevision":state.source_revision,"activeRevision":revision,"activeSequence":state.sequence}, - "authentication":{"oidc":{"issuer":state.mint_origin(),"audience":state.audience(),"allowedAlgorithm":"ES256","accessTokenType":"at+jwt","scopeClaim":"scope","scopeSeparator":" ","allowedClients":clients.clients.iter().filter(|client| !client.access_profiles.is_empty() || client.allow_breg_access).map(|client|&client.id).collect::>(),"deniedKids":[],"maxTokenLifetimeSeconds":300,"leewayMilliseconds":30000,"jwksSource":{"kind":"static","documentRef":"secret:file/mint-jwks"}},"authorityClaims":{"principal":"registry_principal","purpose":"registry_purpose"}}, + "authentication":{"oidc":{"issuer":state.issuer_origin(),"audience":state.audience(),"allowedAlgorithm":"RS256","accessTokenType":"at+jwt","scopeClaim":"scope","scopeSeparator":" ","allowedClients":clients.clients.iter().filter(|client| !client.access_profiles.is_empty() || client.allow_breg_access).map(|client|&client.id).collect::>(),"deniedKids":[],"maxTokenLifetimeSeconds":300,"leewayMilliseconds":30000,"jwksSource":{"kind":"static","documentRef":"secret:file/issuer-jwks"}},"authorityClaims":{"principal":"registry_principal","purpose":"registry_purpose"}}, "audit":{"hashKeyRef":"secret:file/audit-key"},"cursor":{"secretRef":"secret:file/cursor-key"},"eventDestinations":destinations }), ) diff --git a/crates/registry-bregctl/src/dev/events.rs b/crates/registry-bregctl/src/dev/events.rs index 44866b0686..966808efce 100644 --- a/crates/registry-bregctl/src/dev/events.rs +++ b/crates/registry-bregctl/src/dev/events.rs @@ -553,7 +553,7 @@ mod tests { if trigger == EventTrigger::RequestLifecycle { project["accessProfiles"] = json!([{ "id":"operator", "default":true, "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"record", "operations":["create", "get", "list", "patch", "submit_request", "approve_request", "apply_request"], "readableFields":["label", "target", "proposed-label"], "writableFields":["label", "target", "proposed-label"], "rowBoundaries":[], diff --git a/crates/registry-bregctl/src/dev/examples.rs b/crates/registry-bregctl/src/dev/examples.rs index 9eb03328a1..ebb83db781 100644 --- a/crates/registry-bregctl/src/dev/examples.rs +++ b/crates/registry-bregctl/src/dev/examples.rs @@ -705,13 +705,7 @@ async fn execute( bail!("scenario client/profile is not explicitly declared in dev clients"); } if !native.contains_key(&step.client) { - let mint = state - .binaries - .get("mint") - .map(|b| b.path.clone()) - .map(Ok) - .unwrap_or_else(|| super::executable("mint", None))?; - super::token(&mint, state, &step.client)?; + super::token(state, &step.client)?; let token = private::read( &state .root() @@ -1406,7 +1400,7 @@ mod tests { .iter_mut() .find(|profile| profile["id"] == "editor") .unwrap(); - let grant = editor["grants"] + let grant = editor["permissions"] .as_array_mut() .unwrap() .iter_mut() @@ -1436,7 +1430,7 @@ mod tests { .iter_mut() .find(|profile| profile["id"] == "editor") .unwrap(); - let grant = editor["grants"] + let grant = editor["permissions"] .as_array_mut() .unwrap() .iter_mut() diff --git a/crates/registry-bregctl/src/dev/export_client.rs b/crates/registry-bregctl/src/dev/export_client.rs index 51c3c314f9..f628f7425e 100644 --- a/crates/registry-bregctl/src/dev/export_client.rs +++ b/crates/registry-bregctl/src/dev/export_client.rs @@ -66,7 +66,12 @@ pub(super) fn run(args: ExportClientArgs) -> Result { "client":client.id,"accessProfiles":client.access_profiles, "accessProfilesText":client.access_profiles.join(", "), "clientIdFile":id_path,"assertionKeyFile":key_path, - "bregUrl":state.breg_origin(),"tokenEndpoint":format!("{}/token",state.mint_origin()), + "bregUrl":state.breg_origin(), + "issuer":state.issuer_origin(), + "tokenEndpoint":format!("{}/oauth2/token",state.issuer_origin()), + "clientAssertionAudience":state.issuer_origin(), + "resource":state.audience(), + "scopes":client.scopes, "audience":state.audience()}), ) } diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index 400178ab74..7cb52d5c93 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -70,8 +70,8 @@ pub struct DevArgs { enum DevAction { /// Start or reuse the project's retained local database and services. /// - /// A resident supervisor owns this project's PostgreSQL container plus its - /// local Mint and Base Registry Engine (BReg) children. The database runs + /// A resident supervisor owns this project's PostgreSQL and ThunderID + /// containers plus its Base Registry Engine (BReg) child. The database runs /// the pinned image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, /// which the supervisor pulls on the first start. Each supervised @@ -86,6 +86,8 @@ enum DevAction { Events(EventsArgs), /// Copy an explicitly selected retained local client credential pair. ExportClient(export_client::ExportClientArgs), + /// Acquire a fresh local client token and report its private header-file path. + Token(TokenArgs), /// Review or prepare a bounded lookup successor for a stopped retained registry. /// /// `evidencectl source add` drives this operation for an adopter, so the @@ -94,6 +96,15 @@ enum DevAction { PrepareSource(Box), } +#[derive(Debug, Args)] +struct TokenArgs { + /// Registered local client ID. + client: String, + /// Ready local project (defaults to the current directory). + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, +} + #[derive(Debug, Args)] struct StartArgs { /// Existing authored registry project. Its package environment must be local. @@ -106,17 +117,26 @@ struct StartArgs { /// Registry loopback port on first start (default 8090; retained for restarts). #[arg(long)] breg_port: Option, - /// Local Mint loopback port on first start (default 8091; retained for restarts). + /// Local issuer loopback port on first start (default 8091; retained for restarts). + #[arg(long)] + issuer_port: Option, + /// Immutable local candidate issuer image ID on first start; retained for restarts. + #[arg(long, value_parser = candidate_issuer_image)] + issuer_image: Option, + /// Retained spelling from earlier Mint-based dev sessions; refused with + /// legacy-session guidance rather than silently ignored. #[arg(long)] mint_port: Option, + /// Retained spelling from earlier Mint-based dev sessions; refused with + /// legacy-session guidance rather than silently ignored. + #[arg(long)] + mint_bin: Option, /// PostgreSQL loopback port on first start (default 55432; retained for restarts). #[arg(long)] database_port: Option, #[arg(long, hide = true)] breg_bin: Option, #[arg(long, hide = true)] - mint_bin: Option, - #[arg(long, hide = true)] docker_bin: Option, } @@ -149,8 +169,6 @@ pub struct SupervisorArgs { #[arg(long)] breg_bin: PathBuf, #[arg(long)] - mint_bin: PathBuf, - #[arg(long)] docker_bin: PathBuf, } @@ -162,7 +180,9 @@ struct State { owner: String, status: Status, breg_port: u16, - mint_port: u16, + issuer_port: u16, + #[serde(default)] + issuer_image: Option, database_port: u16, /// Kernel-selected loopback receiver port, retained with destination bindings. #[serde(default)] @@ -242,8 +262,8 @@ impl State { fn breg_origin(&self) -> String { format!("http://127.0.0.1:{}", self.breg_port) } - fn mint_origin(&self) -> String { - format!("http://127.0.0.1:{}", self.mint_port) + fn issuer_origin(&self) -> String { + format!("http://127.0.0.1:{}", self.issuer_port) } fn audience(&self) -> String { format!("urn:breg:dev:{}", self.owner) @@ -267,11 +287,12 @@ impl State { Ok( json!({"ok":true,"command":"dev","status":self.status,"project":self.project, "stateFile":self.root().join("state.json"),"runtimeConfig":self.root().join("runtime.yaml"), - "bregUrl":self.breg_origin(),"tokenEndpoint":format!("{}/token",self.mint_origin()), + "bregUrl":self.breg_origin(),"issuer":self.issuer_origin(),"tokenEndpoint":format!("{}/oauth2/token",self.issuer_origin()), + "clientAssertionAudience":self.issuer_origin(),"resource":self.audience(), "webhookUrl":self.webhook_port.map(|port|format!("http://127.0.0.1:{port}/events")), "eventsFile":self.webhook_port.map(|_|self.root().join("events.jsonl")), "audience":self.audience(),"packageRevision":self.package_revision,"packageSequence":self.sequence,"activationPending":!self.activated, - "clients":clients.clients.iter().map(|client|json!({"id":client.id,"accessProfiles":client.access_profiles, + "clients":clients.clients.iter().map(|client|json!({"id":client.id,"accessProfiles":client.access_profiles,"scopes":client.scopes, "clientIdFile":client.client_id_file.clone().unwrap_or_else(||self.root().join("credentials").join(&client.id).join("client-id")), "assertionKeyFile":client.assertion_key_file.clone().unwrap_or_else(||self.root().join("credentials").join(&client.id).join("assertion-key.jwk"))})).collect::>()}), ) @@ -289,11 +310,48 @@ pub fn run(args: DevArgs) -> Result { } Some(DevAction::Start(args)) => start(args), Some(DevAction::ExportClient(args)) => export_client::run(args), + Some(DevAction::Token(args)) => fresh_token(&args.project, &args.client), Some(DevAction::PrepareSource(args)) => prepare_source::run(*args), None => start(args.start), } } +fn fresh_token(project_path: &Path, client: &str) -> Result { + if !config::identifier(client) { + bail!("a registered bounded local client ID is required"); + } + let project = project(project_path)?; + private::check(&project.join(".breg"), true)?; + let root = project.join(".breg/dev"); + let state = read_state(&root)?; + if !matches!(state.status, Status::Ready) + || !control(&root, "status").is_ok_and(|status| status == "ready") + { + bail!("the local development session must be ready before requesting a token"); + } + // Resolve admission before opening any caller-derived credential path. + let clients: Clients = + serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; + if !clients + .clients + .iter() + .any(|configured| configured.id == client) + { + bail!("the local client is not registered"); + } + token(&state, client)?; + let credential = Zeroizing::new(private::read( + &root.join("secrets").join(format!("{client}-token")), + 65536, + )?); + let mut header = Zeroizing::new(b"Authorization: Bearer ".to_vec()); + header.extend_from_slice(&credential); + header.push(b'\n'); + let output = root.join("secrets").join(format!("{client}.header")); + private::replace(&output, &header)?; + Ok(json!({"ok":true,"command":"dev token","headerFile":output})) +} + fn project(path: &Path) -> Result { if !fs::symlink_metadata(path)?.is_dir() { bail!("project must be an ordinary existing directory"); @@ -337,11 +395,25 @@ fn clients_file( fn read_state(root: &Path) -> Result { private::check(root, true)?; - let state: State = serde_json::from_slice(&private::read(&root.join("state.json"), MAX_BYTES)?) - .map_err(|_| { - anyhow::anyhow!("retained dev state is invalid; preserve it for inspection") - })?; - if state.version != 1 + let bytes = private::read(&root.join("state.json"), MAX_BYTES)?; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct VersionProbe { + version: u8, + mint_port: Option, + } + let invalid = || anyhow::anyhow!("retained dev state is invalid; preserve it for inspection"); + let version: VersionProbe = serde_json::from_slice(&bytes).map_err(|_| invalid())?; + if version.version == 1 && version.mint_port.is_some() { + bail!( + "this retained dev session still records its Mint-based issuer (state v1). \ + This build does not implement retained issuer migration; keep the \ + matching Mint-era bregctl and issuer for this session until a \ + verified migration is available. Nothing was changed" + ); + } + let state: State = serde_json::from_slice(&bytes).map_err(|_| invalid())?; + if state.version != 2 || state.sequence == 0 || state.baseline_runtime != (state.sequence > 1).then(|| { @@ -357,17 +429,36 @@ fn read_state(root: &Path) -> Result { { bail!("retained dev state ownership is invalid; no resources were changed"); } - ports(state.breg_port, state.mint_port, state.database_port)?; + if let Some(image) = &state.issuer_image { + candidate_issuer_image(image).map_err(anyhow::Error::msg)?; + } + ports(state.breg_port, state.issuer_port, state.database_port)?; if state.webhook_port.is_some_and(|port| { - port == 0 || [state.breg_port, state.mint_port, state.database_port].contains(&port) + port == 0 || [state.breg_port, state.issuer_port, state.database_port].contains(&port) }) { bail!("retained webhook receiver needs a distinct nonzero loopback port"); } Ok(state) } -fn ports(breg: u16, mint: u16, database: u16) -> Result<()> { - if breg == 0 || mint == 0 || database == 0 || BTreeSet::from([breg, mint, database]).len() != 3 +fn candidate_issuer_image(value: &str) -> std::result::Result { + if value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) { + Ok(value.to_owned()) + } else { + Err("issuer image must be an immutable local sha256: image ID with 64 lowercase hexadecimal digits".into()) + } +} + +fn ports(breg: u16, issuer: u16, database: u16) -> Result<()> { + if breg == 0 + || issuer == 0 + || database == 0 + || BTreeSet::from([breg, issuer, database]).len() != 3 { bail!("three distinct nonzero loopback ports are required"); } @@ -384,7 +475,7 @@ fn receiver_port(state: &State) -> Result { loop { let listener = TcpListener::bind(("127.0.0.1", 0))?; let port = listener.local_addr()?.port(); - if ![state.breg_port, state.mint_port, state.database_port].contains(&port) { + if ![state.breg_port, state.issuer_port, state.database_port].contains(&port) { return Ok(port); } } @@ -432,7 +523,7 @@ fn prepare_receiver(state: &mut State, clients: &Clients) -> Result<()> { /// Name the first journey step whose access profile no local client binds. /// /// The schema-test stage makes the same lookup, but by then the database has -/// been pulled and Mint is serving. Refusing here, before any service starts, +/// been pulled and the issuer is serving. Refusing here, before any service starts, /// tells the author which profile the clients file still lacks while the fix /// is one edit away. fn bind_journey_profiles(journeys: &[u8], clients: &Clients) -> Result<()> { @@ -538,6 +629,14 @@ fn start(args: StartArgs) -> Result { } else { None }; + if args.mint_port.is_some() || args.mint_bin.is_some() { + bail!( + "--mint-port and --mint-bin named the Mint-based issuer used by earlier dev sessions. \ + Use --issuer-port for a new owned dev issuer. A retained Mint session \ + must stay with its matching Mint-era tools until a verified migration \ + is available" + ); + } let clients_file = clients_file(args.clients_file.as_deref(), existing.as_ref(), &project)?; let client_bytes = crate::read_bounded_source_file(&clients_file, "dev.clients", "clients", MAX_BYTES) @@ -558,13 +657,20 @@ fn start(args: StartArgs) -> Result { Some(state) if digest != state.source_digest || args.breg_port.is_some_and(|p| p != state.breg_port) - || args.mint_port.is_some_and(|p| p != state.mint_port) + || args.issuer_port.is_some_and(|p| p != state.issuer_port) + || args + .issuer_image + .as_ref() + .is_some_and(|image| Some(image) != state.issuer_image.as_ref()) || args.database_port.is_some_and(|p| p != state.database_port) => { if state.container_id.is_some() { - bail!("authored package, clients or ports differ from the retained development session, which still holds records; run bregctl dev stop --remove to discard them and start again from the edited inputs, or copy the authored files to a new project directory to keep the records. Use the normal reviewed package lifecycle for an operated upgrade"); + bail!("authored package, clients, ports or issuer image differ from the retained development session, which still holds records; run bregctl dev stop --remove to discard them and start again from the edited inputs, or copy the authored files to a new project directory to keep the records. Use the normal reviewed package lifecycle for an operated upgrade"); } let _supervisor_lock = completed_supervisor_lock(&root, &state.status)?; + // A removed BREG database does not imply its separately owned + // issuer state has been discarded. The explicit --remove path + // clears both before a changed source can replace this directory. fs::remove_dir_all(&root).context("cannot replace the owned development session")?; previous = Some(state); None @@ -589,7 +695,7 @@ fn start(args: StartArgs) -> Result { } let previous = previous.as_ref(); let mut state = State { - version: 1, + version: 2, project: project.clone(), owner: uuid::Uuid::new_v4().to_string(), status: Status::Stopped, @@ -597,10 +703,14 @@ fn start(args: StartArgs) -> Result { .breg_port .or(previous.map(|s| s.breg_port)) .unwrap_or(8090), - mint_port: args - .mint_port - .or(previous.map(|s| s.mint_port)) + issuer_port: args + .issuer_port + .or(previous.map(|s| s.issuer_port)) .unwrap_or(8091), + issuer_image: args + .issuer_image + .clone() + .or_else(|| previous.and_then(|s| s.issuer_image.clone())), database_port: args .database_port .or(previous.map(|s| s.database_port)) @@ -622,8 +732,8 @@ fn start(args: StartArgs) -> Result { binaries: BTreeMap::new(), failure: None, }; - ports(state.breg_port, state.mint_port, state.database_port)?; - for port in [state.breg_port, state.mint_port, state.database_port] { + ports(state.breg_port, state.issuer_port, state.database_port)?; + for port in [state.breg_port, state.issuer_port, state.database_port] { probe(port)?; } if !compiled.event_deliveries().deliveries.is_empty() { @@ -638,18 +748,21 @@ fn start(args: StartArgs) -> Result { prepare_receiver(&mut state, &clients)?; verify_outputs(&state)?; let breg = executable("breg", args.breg_bin.as_deref())?; - let mint = executable("mint", args.mint_bin.as_deref())?; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or - // launches the supervisor: a breg or mint from another release has to be - // named here, while the terminal that asked for the start is reading. + // launches the supervisor: a breg from another release has to be named + // here, while the terminal that asked for the start is reading. The dev + // issuer is the pinned upstream container; no token-issuer binary is + // installed or version-locked anymore. state.binaries = BTreeMap::from([ ("breg".into(), binary(&root, &breg)?), - ("mint".into(), binary(&root, &mint)?), ("docker".into(), binary(&root, &docker)?), ]); matching_versions(&state.binaries)?; - for port in [state.breg_port, state.mint_port] { + // A prior supervisor may have exited without reaching cleanup. Reclaim + // only this session's issuer before testing whether its port is free. + stop_issuer(&docker, &state)?; + for port in [state.breg_port, state.issuer_port] { probe(port)?; } if let Some(port) = state.webhook_port { @@ -686,8 +799,6 @@ fn start(args: StartArgs) -> Result { .arg(&root) .arg("--breg-bin") .arg(breg) - .arg("--mint-bin") - .arg(mint) .arg("--docker-bin") .arg(docker) .stdin(Stdio::null()) @@ -842,10 +953,11 @@ fn stop(project_path: &Path, remove: bool, docker_bin: Option<&Path>) -> Result< } // No PID-based recovery: unrelated reused PIDs must never be signalled. let _supervisor_lock = completed_supervisor_lock(&root, &state.status)?; - for port in [state.breg_port, state.mint_port] { + let docker = executable("docker", docker_bin)?; + stop_issuer(&docker, &state)?; + for port in [state.breg_port, state.issuer_port] { probe(port)?; } - let docker = executable("docker", docker_bin)?; // Remove mode tolerates a container already taken by hand: reclaim verifies // ownership of whatever is still there and forgets the rest, so skip the // inspection (and the stop it guards) when nothing is listed under this name. @@ -1014,22 +1126,16 @@ pub fn run_supervisor(args: SupervisorArgs) -> Result<()> { } database(&args.docker_bin, &mut state)?; ensure_active(&terminate)?; - children.mint = Some(service( - &args.mint_bin, - &["serve", "--config"], - &root.join("mint/mint.yaml"), - &root, - "mint", - )?); - ready( - &format!("{}/ready", state.mint_origin()), - children.mint.as_mut().context("Mint child missing")?, - &terminate, - )?; + // The dev issuer is the pinned upstream ThunderID container, owned by + // this session through the shared tooling crate: setup, bootstrap + // provisioning, serving, and readiness all live there, and its state + // is retained across stop/start exactly like the registry's own. + issuer(&args.docker_bin, &state, &clients)?; + ensure_active(&terminate)?; if state.package_revision.is_none() { // The schema-test rehearsal presents these tokens to its own - // disposable runtime; the seed below mints its own. - tokens(&args.mint_bin, &state, &clients)?; + // disposable runtime; the seed below acquires its own. + tokens(&state, &clients)?; package(&args.docker_bin, &mut state, &clients)?; } ensure_active(&terminate)?; @@ -1084,7 +1190,7 @@ pub fn run_supervisor(args: SupervisorArgs) -> Result<()> { // A client token lives 300 seconds, which the child and readiness // deadlines of a slow first start can exhaust before the seed runs. // Mint the seeding tokens once the registry is ready, not before it. - tokens(&args.mint_bin, &state, &clients)?; + tokens(&state, &clients)?; seed(&mut state, &clients)?; let control_root = control_directory(&root)?; private::directory(&control_root)?; @@ -1128,10 +1234,12 @@ pub fn run_supervisor(args: SupervisorArgs) -> Result<()> { Ok(stop_stream) })(); let child_cleanup = children.stop(); + let issuer_cleanup = stop_issuer(&args.docker_bin, &state); let database_cleanup = stop_database(&args.docker_bin, &state); let socket_cleanup = remove_socket(&root); if result.is_err() || child_cleanup.is_err() + || issuer_cleanup.is_err() || database_cleanup.is_err() || socket_cleanup.is_err() { @@ -1141,6 +1249,7 @@ pub fn run_supervisor(args: SupervisorArgs) -> Result<()> { state.failure = [ result.as_ref().err(), child_cleanup.as_ref().err(), + issuer_cleanup.as_ref().err(), database_cleanup.as_ref().err(), socket_cleanup.as_ref().err(), ] @@ -1151,6 +1260,7 @@ pub fn run_supervisor(args: SupervisorArgs) -> Result<()> { state.save()?; result?; child_cleanup?; + issuer_cleanup?; database_cleanup?; socket_cleanup?; unreachable!("a failed cleanup returned its error"); @@ -1185,7 +1295,6 @@ fn read_control_command(stream: &mut impl Read) -> Result> { #[derive(Default)] struct Children { breg: Option, - mint: Option, receiver: Option, } impl Children { @@ -1193,7 +1302,7 @@ impl Children { if self.receiver.as_ref().is_some_and(events::Receiver::exited) { return Ok(true); } - for child in [&mut self.breg, &mut self.mint].into_iter().flatten() { + for child in [&mut self.breg].into_iter().flatten() { if child.try_wait()?.is_some() { return Ok(true); } @@ -1202,7 +1311,7 @@ impl Children { } fn stop(&mut self) -> Result<()> { let mut error = None; - for owned in [&mut self.breg, &mut self.mint] { + for owned in [&mut self.breg] { if let Some(mut child) = owned.take() { if let Err(cause) = stop_child(&mut child) { error = Some(cause); @@ -1324,23 +1433,19 @@ fn reported_version(binary: &Binary) -> Option<&str> { } binary.version.split_whitespace().nth(1) } -/// Refuse a session whose breg or mint comes from another release. The three -/// executables share a package format, a token shape and a schema, so an older -/// breg beside this bregctl fails deep inside a supervised phase, where the +/// Refuse a session whose BREG executable comes from another release. An older +/// BREG beside this bregctl fails deep inside a supervised phase, where the /// cause reads as an unrelated refusal about the package or the database. -/// Docker belongs to no release of this stack and is never compared. +/// Docker and the source-pinned issuer belong to no BREG release comparison. fn matching_versions(binaries: &BTreeMap) -> Result<()> { let own = registry_platform_buildinfo::DISPLAY_VERSION; - for name in ["breg", "mint"] { - let Some(prerequisite) = binaries.get(name) else { - continue; - }; + if let Some(prerequisite) = binaries.get("breg") { let Some(reported) = reported_version(prerequisite) else { - continue; + return Ok(()); }; if reported != own { bail!( - "the installed {name} at {} reports version {reported}, and this bregctl reports version {own}. A local session runs breg, mint and bregctl together, so install all three from the same release, or put the matching build first on PATH", + "the installed breg at {} reports version {reported}, and this bregctl reports version {own}. A local session runs breg and bregctl together, so install both from the same release, or put the matching build first on PATH", prerequisite.path.display() ); } @@ -1926,43 +2031,113 @@ fn stop_database(docker: &Path, state: &State) -> Result<()> { Ok(()) } -fn tokens(mint: &Path, state: &State, clients: &Clients) -> Result<()> { +fn tokens(state: &State, clients: &Clients) -> Result<()> { for client in &clients.clients { - token(mint, state, &client.id)?; + token(state, &client.id)?; } Ok(()) } -fn token(mint: &Path, state: &State, id: &str) -> Result<()> { +/// One dev credential, acquired through the shared private-key-JWT provider +/// exactly as a relying client would: no token-issuer binary is spawned, the +/// assertion key never leaves the session's private credentials tree, and the +/// credential is stored owner-only for the seeding and rehearsal steps. +fn token(state: &State, id: &str) -> Result<()> { + use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; + let root = state.root(); - let bytes = Zeroizing::new(command( - Command::new(mint) - .arg("token") - .arg("--url") - .arg(format!("{}/token", state.mint_origin())) - .arg("--client-id") - .arg(id) - .arg("--key") - .arg(root.join("credentials").join(id).join("assertion-key.jwk")), - &root, - "token", - None, - )?); - let value = std::str::from_utf8(&bytes) - .context("Mint token output must be ASCII")? - .trim(); - if value.len() > 65536 - || value.split('.').count() != 3 - || value.chars().any(char::is_whitespace) - { - bail!("Mint returned an invalid compact token"); + let issuer = state.issuer_origin(); + let endpoint: url::Url = format!("{issuer}/oauth2/token") + .parse() + .context("the dev issuer token endpoint is invalid")?; + let key_bytes = private::read( + &root.join("credentials").join(id).join("assertion-key.jwk"), + 4096, + )?; + let key_text = + String::from_utf8(key_bytes.to_vec()).context("the retained client key is unreadable")?; + let key = registry_platform_crypto::PrivateJwk::parse(&key_text) + .map_err(|_| anyhow::anyhow!("the retained client key is unusable"))?; + let client: Clients = + serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; + let scopes = client + .clients + .iter() + .find(|client| client.id == id) + .with_context(|| format!("the retained client {id} is not registered"))? + .scopes + .clone(); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new(endpoint, id.to_owned(), key) + // ThunderID v1.0.1 checks the assertion audience against the + // issuer identifier, not the token endpoint. + .with_audience(issuer.clone()) + .with_resource(state.audience()) + .with_scopes(scopes), + ) + .map_err(|error| anyhow::anyhow!("the dev token provider is unusable: {error}"))?; + let value = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("cannot build the dev token runtime")? + .block_on(provider.bearer_token()) + .map_err(|error| anyhow::anyhow!("the dev issuer declined to issue a token: {error}"))?; + let header = value.authorization_header_value(); + let text = header + .to_str() + .context("the issued credential is not header-safe")? + .strip_prefix("Bearer ") + .unwrap_or_default() + .to_owned(); + if text.len() > 65536 || text.split('.').count() != 3 { + bail!("the dev issuer returned an invalid compact token"); } private::replace( &root.join("secrets").join(format!("{id}-token")), - value.as_bytes(), + text.as_bytes(), ) } +/// Bring the session's issuer container up: one-time setup and bootstrap +/// provisioning against the rendered registration, then serving, then the +/// bounded discovery wait. The functional half of readiness is the token +/// acquisition above. +fn issuer(docker: &Path, state: &State, _clients: &Clients) -> Result<()> { + let root = state.root(); + let pin = registry_thunderid_tooling::version::ThunderIdPin::load()?; + let image = state.issuer_image.as_deref().unwrap_or(&pin.image); + let state_root = root.join("issuer"); + let session = registry_thunderid_tooling::container::Session { + label: &format!("breg-dev-{}", state.instance_id), + id: &state.instance_id, + port: state.issuer_port, + state_root: &state_root, + image, + }; + let jwks = registry_thunderid_tooling::local::start(&session, docker, &mut || false)?; + private::replace( + &root.join("secrets/issuer-jwks"), + &serde_json::to_vec(&jwks)?, + ) +} + +fn stop_issuer(docker: &Path, state: &State) -> Result<()> { + let state_root = state.root().join("issuer"); + if !state_root.join("session.json").exists() { + return Ok(()); + } + let pin = registry_thunderid_tooling::version::ThunderIdPin::load()?; + let image = state.issuer_image.as_deref().unwrap_or(&pin.image); + let session = registry_thunderid_tooling::container::Session { + label: &format!("breg-dev-{}", state.instance_id), + id: &state.instance_id, + port: state.issuer_port, + state_root: &state_root, + image, + }; + registry_thunderid_tooling::local::stop(&session, docker).map_err(Into::into) +} + fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { let root = state.root(); // The schema-test database is disposable. A failed rehearsal is rebuilt; diff --git a/crates/registry-bregctl/src/dev/prepare_source.rs b/crates/registry-bregctl/src/dev/prepare_source.rs index bc6bf90c81..1dee55e0eb 100644 --- a/crates/registry-bregctl/src/dev/prepare_source.rs +++ b/crates/registry-bregctl/src/dev/prepare_source.rs @@ -158,6 +158,13 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { let bytes = crate::read_bounded_source_file(&state.clients_file, "dev.clients", "clients", MAX_BYTES) .map_err(|_| anyhow::anyhow!("clients file is missing or unsafe"))?; + let clients = config::clients(&bytes)?; + let client_scopes: Vec<&String> = clients + .clients + .iter() + .find(|client| client.id == "source") + .map(|client| client.scopes.iter().collect()) + .unwrap_or_default(); let captured = capture(&project, &bytes)?; if captured.digest != state.source_digest { bail!("authored inputs differ from the retained session; restore the recorded inputs before preparing a source. Arbitrary changes require the reviewed package lifecycle"); @@ -166,7 +173,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { let compiled = crate::compile(&project, crate::ProfileArg::Production, "prepare-source") .map_err(|_| anyhow::anyhow!("project no longer compiles"))?; let mut report = json!({"ok":true,"command":"dev prepare-source","project":project,"status":"inspect","recoveredPriorApply":recovered, - "entities":inventory(&docs,&compiled),"bregUrl":state.breg_origin(),"tokenEndpoint":format!("{}/token",state.mint_origin()),"audience":state.audience()}); + "entities":inventory(&docs,&compiled),"bregUrl":state.breg_origin(),"issuer":state.issuer_origin(),"tokenEndpoint":format!("{}/oauth2/token",state.issuer_origin()),"clientAssertionAudience":state.issuer_origin(),"resource":state.audience(),"scopes":client_scopes,"audience":state.audience()}); let Some(entity_id) = &args.entity else { if args.apply || args.selector_field.is_some() @@ -324,7 +331,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { } registry["accessProfiles"].as_array_mut().context("access profiles missing")?.push(json!({ "id":args.access_profile,"principalClaim":"registry_principal","requiredScopes":[scope],"requiredPurposes":["evidence-source-read"], - "grants":[{"entity":entity_id,"operations":["lookup"],"readableFields":grant_fields, + "permissions":[{"entity":entity_id,"operations":["lookup"],"readableFields":grant_fields, "lookups":[{"selector":args.selector_profile,"valueOrigin":"request"}],"rowBoundaries":row_boundaries}]})); let sequence = state .sequence @@ -411,6 +418,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { ("selectorProfile", json!(args.selector_profile)), ("accessProfile", json!(args.access_profile)), ("client", json!(args.client)), + ("preparedClientScopes", json!(&client.scopes)), ("readableFields", json!(args.readable_fields)), ("rowScope", row_scope), ("packageSequence", json!(sequence)), @@ -523,11 +531,10 @@ fn finish(original: &State, transition: &Transition) -> Result<()> { fs::rename(staging, &credential)?; File::open(root.join("credentials"))?.sync_all()?; } - let public: Value = - serde_json::from_slice(&private::read(&credential.join("public.jwk"), MAX_BYTES)?)?; - private::replace(&root.join("mint/clients").join(format!("{}.yaml",transition.client.id)),serde_norway::to_string(&json!({ - "clientId":transition.client.id,"principal":format!("urn:breg:dev:{}",transition.client.id), - "authorization":{"scopes":transition.client.scopes,"claims":transition.client.claims},"keys":[public]}))?.as_bytes())?; + // The reviewed successor adds a native machine agent, role, and resource + // permission for this lookup-only client. Publish the whole derived issuer + // description so the next owned restart bootstraps the registration. + config::refresh_issuer_registration(original, &transition.clients)?; for (path, bytes) in &transition.replacements { // Authoring files are ordinary project files, not private state. The // source capture checked their safety; no unrelated file is replaced. @@ -690,13 +697,17 @@ mod tests { .remove("accessProfiles") .unwrap(); let mut operator = profiles.as_array_mut().unwrap().remove(0); - let mut grants = operator.as_object_mut().unwrap().remove("grants").unwrap(); - let mut grant = grants.as_array_mut().unwrap().remove(0); - grant.as_object_mut().unwrap().remove("entity"); + let mut permissions = operator + .as_object_mut() + .unwrap() + .remove("permissions") + .unwrap(); + let mut permission = permissions.as_array_mut().unwrap().remove(0); + permission.as_object_mut().unwrap().remove("entity"); operator .as_object_mut() .unwrap() - .extend(grant.as_object().unwrap().clone()); + .extend(permission.as_object().unwrap().clone()); let module = json!({"id":"local-authority","version":"1","extendEntities":[{"entity":"record","accessProfiles":[operator]}]}); let module_bytes = serde_norway::to_string(&module).unwrap().into_bytes(); let parsed = registry_breg::contract::parse_module_yaml(&module_bytes).unwrap(); @@ -793,6 +804,12 @@ mod tests { .unwrap_or_else(|failure| panic!("{}", serde_json::to_string(&failure).unwrap())); let preview = run(args(&state)).unwrap(); assert_eq!(preview["packageSequence"], 2); + assert_eq!( + preview["preparedClientScopes"], + json!(["registry:source-reader:lookup"]) + ); + assert_eq!(preview["resource"], preview["audience"]); + assert_eq!(preview["clientAssertionAudience"], preview["issuer"]); assert_eq!( preview["changeSet"]["migrationPlan"]["statements"], json!([]) @@ -816,6 +833,36 @@ mod tests { selected.apply = true; let applied = run(selected).unwrap(); assert_eq!(applied["status"], "prepared"); + assert_eq!( + applied["preparedClientScopes"], + preview["preparedClientScopes"] + ); + let agents = root.join("issuer/registry-schema/agents"); + let registrations: Vec = fs::read_dir(&agents) + .unwrap() + .map(|entry| { + let entry = entry.unwrap(); + serde_norway::from_slice(&private::read(&entry.path(), MAX_BYTES).unwrap()).unwrap() + }) + .collect(); + let source = registrations + .iter() + .find(|agent| agent["inboundAuthConfig"][0]["config"]["clientId"] == "source-reader") + .expect("the prepared source has a native machine registration"); + assert_eq!(source["attributes"]["registry_principal"], "source-reader"); + assert!(!root.join("mint/clients/source-reader.yaml").exists()); + let roles = root.join("issuer/resources/roles"); + let grants: Vec = fs::read_dir(roles) + .unwrap() + .map(|entry| { + serde_norway::from_slice(&private::read(&entry.unwrap().path(), MAX_BYTES).unwrap()) + .unwrap() + }) + .collect(); + assert!(grants + .iter() + .any(|role| role["permissions"][0]["permissions"] + == json!(["registry:source-reader:lookup"]))); let prepared: Value = serde_norway::from_slice(&fs::read(path).unwrap()).unwrap(); assert_eq!(prepared["accessProfiles"].as_array().unwrap().len(), 1); assert_eq!(prepared["accessProfiles"][0]["id"], "source-reader"); @@ -853,9 +900,7 @@ mod tests { ) .unwrap(); let operator_path = root.join("credentials/operator/assertion-key.jwk"); - let issuer_path = root.join("credentials/issuer/assertion-key.jwk"); let operator = private::read(&operator_path, MAX_BYTES).unwrap(); - let issuer = private::read(&issuer_path, MAX_BYTES).unwrap(); let originals = BTreeMap::from([ ( state.project.join("registry.yaml"), @@ -874,7 +919,6 @@ mod tests { assert_eq!(pending.seeded, state.seeded); assert!(!pending.activated); assert_eq!(private::read(&operator_path, MAX_BYTES).unwrap(), operator); - assert_eq!(private::read(&issuer_path, MAX_BYTES).unwrap(), issuer); let key_path = root.join("credentials/source-reader/assertion-key.jwk"); let key = private::read(&key_path, MAX_BYTES).unwrap(); let mut selected = args(&state); @@ -1072,7 +1116,7 @@ mod tests { let root = state.root(); let path = state.project.join("registry.yaml"); let mut model: Value = serde_norway::from_slice(&fs::read(&path).unwrap()).unwrap(); - model["accessProfiles"][0]["grants"][0]["rowBoundaries"] = + model["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = json!([{"field":field,"claim":"existing_row","operator":operator}]); let model = serde_norway::to_string(&model).unwrap().into_bytes(); fs::write(&path, &model).unwrap(); @@ -1123,9 +1167,9 @@ mod tests { target["id"] = json!("organization"); target["route"] = json!("organizations"); model["entities"].as_array_mut().unwrap().push(target); - let mut target_grant = model["accessProfiles"][0]["grants"][0].clone(); + let mut target_grant = model["accessProfiles"][0]["permissions"][0].clone(); target_grant["entity"] = json!("organization"); - model["accessProfiles"][0]["grants"] + model["accessProfiles"][0]["permissions"] .as_array_mut() .unwrap() .push(target_grant); @@ -1135,7 +1179,7 @@ mod tests { .unwrap() .push(json!({"kind":"unique","fields":["organization"]})); for key in ["readableFields", "writableFields"] { - model["accessProfiles"][0]["grants"][0][key] + model["accessProfiles"][0]["permissions"][0][key] .as_array_mut() .unwrap() .push(json!("organization")); diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 035138e22b..d0813ceca9 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -27,12 +27,13 @@ seed: [] ) .expect("clients"); let state = State { - version: 1, + version: 2, project: project.clone(), owner: uuid::Uuid::new_v4().to_string(), status: Status::Stopped, breg_port: 8094, - mint_port: 8095, + issuer_port: 8095, + issuer_image: None, database_port: 55448, webhook_port: None, clients_file: project.join("clients.yaml"), @@ -65,11 +66,34 @@ fn initialization_keeps_distinct_keys_and_private_state_without_service_dependen initialize(&state.root(), &state, &clients, &files).expect("initialize"); let root = state.root(); private::validate_tree(&root).expect("all generated state is private"); - let issuer = private::read( - &root.join("credentials/issuer/assertion-key.jwk"), - MAX_BYTES, - ) - .expect("issuer"); + let issuer_state = fs::read_dir(root.join("issuer")) + .expect("the upstream issuer session state exists") + .count(); + assert!(issuer_state > 0); + let server_file = fs::read_dir(root.join("issuer/resources/resource_servers")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let server: Value = + serde_norway::from_slice(&private::read(&server_file, MAX_BYTES).unwrap()).unwrap(); + let resources = server["resources"].as_array().unwrap(); + assert!(resources + .iter() + .any(|resource| resource["handle"] == "registry")); + assert!( + resources.iter().any(|resource| { + resource["handle"] == "generic" + && resource["parent"] == "registry" + && resource["actions"] + .as_array() + .unwrap() + .iter() + .any(|action| action["handle"] == "operate") + }), + "three-segment authored scopes need the upstream parent resource chain" + ); let operator = private::read( &root.join("credentials/operator/assertion-key.jwk"), MAX_BYTES, @@ -80,9 +104,9 @@ fn initialization_keeps_distinct_keys_and_private_state_without_service_dependen MAX_BYTES, ) .expect("source"); - assert!(issuer != operator); + assert!(operator.len() > 32); + assert!(source != operator); assert!(source != operator); - assert!(source != issuer); let report = serde_json::to_string(&state.report().unwrap()).expect("report"); assert!(!report.contains("\"d\"")); let runtime: Value = serde_norway::from_slice( @@ -358,6 +382,34 @@ fn state_refuses_changed_ownership_without_touching_paths() { assert!(read_state(&state.root()).is_err()); } +#[test] +fn a_retained_mint_state_is_named_before_v2_deserialization_without_mutation() { + let (_temp, state, clients, files) = fixture(); + let root = state.root(); + initialize(&root, &state, &clients, &files).unwrap(); + let state_file = root.join("state.json"); + let mut old: Value = + serde_json::from_slice(&private::read(&state_file, MAX_BYTES).unwrap()).unwrap(); + old["version"] = json!(1); + let fields = old.as_object_mut().unwrap(); + let port = fields.remove("issuerPort").unwrap(); + fields.insert("mintPort".to_owned(), port); + let bytes = serde_json::to_vec(&old).unwrap(); + private::replace(&state_file, &bytes).unwrap(); + + let refusal = read_state(&root).unwrap_err().to_string(); + assert!( + refusal.contains("Mint-based issuer (state v1)"), + "{refusal}" + ); + assert!( + refusal.contains("does not implement retained issuer migration"), + "{refusal}" + ); + assert!(!refusal.contains("migrate-issuer PROJECT"), "{refusal}"); + assert_eq!(private::read(&state_file, MAX_BYTES).unwrap(), bytes); +} + #[test] fn occupied_and_ambiguous_ports_are_refused() { assert!(ports(1, 1, 2).is_err()); @@ -422,10 +474,9 @@ fn prerequisites_from_another_release_are_refused_before_the_session_starts() { }; // Docker belongs to no release of this stack and names itself in its own // shape, so it is never compared against the stack's version. - let session = |breg: String, mint: String| { + let session = |breg: String| { BTreeMap::from([ ("breg".to_owned(), installed(&breg)), - ("mint".to_owned(), installed(&mint)), ( "docker".to_owned(), installed("Docker version 29.4.0, build 1a2b3c4"), @@ -433,19 +484,10 @@ fn prerequisites_from_another_release_are_refused_before_the_session_starts() { ]) }; - matching_versions(&session(format!("breg {own}"), format!("mint {own}"))) - .expect("the three binaries of one release start a session"); + matching_versions(&session(format!("breg {own}"))) + .expect("the stack binaries of one release start a session"); - for (name, binaries) in [ - ( - "breg", - session("breg 0.26.1".to_owned(), format!("mint {own}")), - ), - ( - "mint", - session(format!("breg {own}"), "mint 0.26.1".to_owned()), - ), - ] { + for (name, binaries) in [("breg", session("breg 0.26.1".to_owned()))] { let refusal = format!( "{:#}", matching_versions(&binaries).expect_err("a prerequisite from another release") @@ -457,11 +499,8 @@ fn prerequisites_from_another_release_are_refused_before_the_session_starts() { // A prerequisite that declines to identify itself still serves the // session, as the installed lifecycle proof starts one that never answers. - matching_versions(&session( - UNREPORTED_VERSION.to_owned(), - format!("mint {own}"), - )) - .expect("a prerequisite that reports no version is not compared"); + matching_versions(&session(UNREPORTED_VERSION.to_owned())) + .expect("a prerequisite that reports no version is not compared"); // A start resolves the prerequisites and compares them before it inspects // a container or launches the supervisor. @@ -470,21 +509,23 @@ fn prerequisites_from_another_release_are_refused_before_the_session_starts() { fs::create_dir(&prerequisites).unwrap(); for (name, reported) in [ ("breg", "breg 0.26.1".to_owned()), - ("mint", format!("mint {own}")), ("docker", "Docker version 29.4.0, build 1a2b3c4".to_owned()), ] { script(&prerequisites.join(name), &format!("echo '{reported}'")); } + let ports = unused_ports(); let refused = format!( "{:#}", start(StartArgs { project: project.clone(), clients_file: None, - breg_port: None, - mint_port: None, - database_port: None, + breg_port: Some(ports[0]), + issuer_port: Some(ports[1]), + issuer_image: None, + database_port: Some(ports[2]), breg_bin: Some(prerequisites.join("breg")), - mint_bin: Some(prerequisites.join("mint")), + mint_port: None, + mint_bin: None, docker_bin: Some(prerequisites.join("docker")), }) .expect_err("a breg from another release never starts a session") @@ -551,7 +592,7 @@ fn database_roles_have_independent_passwords_and_hmac_files_are_secret_safe() { assert!(runtime.password() != migration.password()); assert!(!environment.contains(runtime.password().unwrap())); assert!(!environment.contains(migration.password().unwrap())); - for file in ["audit-key", "cursor-key", "mint-audit-key"] { + for file in ["audit-key", "cursor-key"] { let bytes = private::read(&root.join("secrets").join(file), 64).unwrap(); assert_eq!(bytes.len(), 43); assert!(bytes @@ -977,12 +1018,13 @@ fn retained_session(project: &Path, container_id: Option) -> State { let captured = capture(project, &client_bytes).unwrap(); private::directory(&project.join(".breg")).unwrap(); let state = State { - version: 1, + version: 2, project: project.to_path_buf(), owner: uuid::Uuid::new_v4().to_string(), status: Status::Stopped, breg_port: 8094, - mint_port: 8095, + issuer_port: 8095, + issuer_image: None, database_port: 55448, webhook_port: None, clients_file: project.join("dev-clients.yaml"), @@ -1010,9 +1052,11 @@ fn start_without_binaries(project: &Path) -> Result { project: project.to_path_buf(), clients_file: None, breg_port: None, - mint_port: None, + issuer_port: None, + issuer_image: None, database_port: None, breg_bin: Some(project.join("missing-breg")), + mint_port: None, mint_bin: None, docker_bin: None, }) @@ -1079,7 +1123,12 @@ fn changed_inputs_replace_a_session_whose_records_were_discarded() { // After `dev stop --remove` nothing remains for the source pin to protect, // so an edited project starts a fresh session on the retained ports. let (_temporary, project) = write_init_project(); - let state = retained_session(&project, None); + let mut state = retained_session(&project, None); + let ports = unused_ports(); + state.breg_port = ports[0]; + state.issuer_port = ports[1]; + state.database_port = ports[2]; + state.save().unwrap(); let previous_key = fs::read(state.root().join("credentials/operator/assertion-key.jwk")).unwrap(); let registry = project.join("registry.yaml"); @@ -1102,10 +1151,10 @@ fn changed_inputs_replace_a_session_whose_records_were_discarded() { assert_eq!( ( replaced.breg_port, - replaced.mint_port, + replaced.issuer_port, replaced.database_port ), - (8094, 8095, 55448) + (ports[0], ports[1], ports[2]) ); assert_eq!(replaced.clients_file, state.clients_file); assert!(replaced.container_id.is_none()); @@ -1141,12 +1190,23 @@ fn export_args(state: &State) -> export_client::ExportClientArgs { } } +/// The rendered machine registration for the fixture's `source` client: the +/// one agent document under the issuer provisioning tree. +fn source_agent_path(state: &State) -> std::path::PathBuf { + let directory = state.root().join("issuer/registry-schema/agents"); + let entry = std::fs::read_dir(&directory) + .expect("the issuer provisioning tree exists") + .find_map(|entry| entry.ok()) + .expect("at least one rendered agent"); + directory.join(entry.path().file_name().expect("a file name")) +} + #[test] fn export_client_copies_a_stopped_retained_pair_and_retries_without_state_changes() { let (_temp, state, clients, files) = fixture(); initialize(&state.root(), &state, &clients, &files).unwrap(); let before = fs::read(state.root().join("state.json")).unwrap(); - let registrations = fs::read(state.root().join("mint/clients/source.yaml")).unwrap(); + let registrations = fs::read(source_agent_path(&state)).unwrap(); let retained_clients = fs::read(state.root().join("clients.json")).unwrap(); // Authored input is deliberately absent: only the retained session is used. let report = export_client::run(export_args(&state)).unwrap(); @@ -1168,10 +1228,7 @@ fn export_client_copies_a_stopped_retained_pair_and_retries_without_state_change fs::read(state.root().join("clients.json")).unwrap(), retained_clients ); - assert_eq!( - fs::read(state.root().join("mint/clients/source.yaml")).unwrap(), - registrations - ); + assert_eq!(fs::read(source_agent_path(&state)).unwrap(), registrations); assert_eq!( fs::read(&export_args(&state).assertion_key_file).unwrap(), key @@ -1286,7 +1343,7 @@ fn plain_init_source_client_has_only_the_explicit_lookup_profile_and_a_distinct_ state.status = Status::Stopped; initialize(&state.root(), &state, &clients, &files).unwrap(); let key = fs::read(state.root().join("credentials/source/assertion-key.jwk")).unwrap(); - for other in ["operator", "reader", "issuer"] { + for other in ["operator", "reader"] { assert_ne!( key, fs::read( @@ -1586,3 +1643,52 @@ fn old_event_free_sessions_keep_working_without_receiver_state() { let report = events::report(&state.root(), false).unwrap(); assert_eq!(report["deliveries"], json!([])); } + +#[test] +fn fresh_token_rejects_path_clients_and_stopped_sessions_without_network() { + let (_temp, state, clients, files) = fixture(); + for client in ["../operator", "/operator", "", "operator/header"] { + assert!(fresh_token(&state.project, client) + .unwrap_err() + .to_string() + .contains("bounded local client")); + } + initialize(&state.root(), &state, &clients, &files).unwrap(); + assert!(fresh_token(&state.project, "operator") + .unwrap_err() + .to_string() + .contains("must be ready")); + assert!(!state.root().join("secrets/operator.header").exists()); +} + +fn unused_ports() -> [u16; 3] { + let sockets = [0; 3].map(|_| std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap()); + sockets + .each_ref() + .map(|socket| socket.local_addr().unwrap().port()) +} + +#[test] +fn candidate_issuer_image_is_immutable_and_retained() { + let image = format!("sha256:{}", "a".repeat(64)); + assert_eq!(candidate_issuer_image(&image).unwrap(), image); + for invalid in [ + "latest", + "ghcr.io/example/candidate:latest", + "sha256:short", + &format!("sha256:{}", "A".repeat(64)), + ] { + assert!(candidate_issuer_image(invalid).is_err()); + } + let (_temporary, mut state, _clients, _files) = fixture(); + state.issuer_image = Some(image.clone()); + let encoded = serde_json::to_vec(&state).unwrap(); + let restored: State = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(restored.issuer_image, Some(image)); + let mut legacy = serde_json::to_value(&state).unwrap(); + legacy.as_object_mut().unwrap().remove("issuerImage"); + assert!(serde_json::from_value::(legacy) + .unwrap() + .issuer_image + .is_none()); +} diff --git a/crates/registry-bregctl/src/init_from_model/render.rs b/crates/registry-bregctl/src/init_from_model/render.rs index 02c66e07f2..1012a4600b 100644 --- a/crates/registry-bregctl/src/init_from_model/render.rs +++ b/crates/registry-bregctl/src/init_from_model/render.rs @@ -420,7 +420,7 @@ fn registry(plan: &Plan) -> String { &format!("requiredScopes: [{}]", scalar(&operate_scope(plan))), ); yaml.line(2, "requiredPurposes: [registry-operations]"); - yaml.line(2, "grants:"); + yaml.line(2, "permissions:"); for entity in &plan.entities { let all: Vec<&str> = entity.all_fields().map(|field| field.id.as_str()).collect(); let filterable: Vec<&str> = entity @@ -452,7 +452,7 @@ fn registry(plan: &Plan) -> String { &format!("requiredScopes: [{}]", scalar(&read_scope(plan))), ); yaml.line(2, "requiredPurposes: [registry-reporting]"); - yaml.line(2, "grants:"); + yaml.line(2, "permissions:"); for entity in readers { let readable: Vec<&str> = entity .all_fields() @@ -1933,7 +1933,7 @@ mod tests { let profiles = registry["accessProfiles"].as_array().expect("profiles"); assert_eq!(profiles[0]["id"], OPERATOR_PROFILE); assert_eq!(profiles[1]["id"], READER_PROFILE); - let reader_person = &profiles[1]["grants"][0]; + let reader_person = &profiles[1]["permissions"][0]; assert_eq!(reader_person["entity"], "person"); let readable = reader_person["readableFields"] .as_array() diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index f372e6256f..cfd04d3f60 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -5656,9 +5656,9 @@ bregctl project lock . ## Run it on your machine `bregctl dev` starts this project as a working registry on loopback: PostgreSQL -in Docker, Registry Mint issuing tokens to the clients in `dev-clients.yaml`, -and Base Registry Engine serving the package it builds and tests from these -files. It needs Docker and the installed `breg` and `mint` binaries. +and the pinned ThunderID issuer in Docker, and Base Registry Engine serving the +package it builds and tests from these files. It needs Docker and the installed +`breg` binary; client registrations come from `dev-clients.yaml`. ```sh bregctl dev @@ -5807,7 +5807,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:generic:operate] requiredPurposes: [registry-operations] - grants: + permissions: - entity: record-group rowBoundaries: [] operations: [create, get, list] @@ -5835,7 +5835,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:generic:read] requiredPurposes: [registry-reporting] - grants: + permissions: - entity: record operations: [get, list] readableFields: [code, label, group, status] @@ -5859,7 +5859,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:evidence:lookup] requiredPurposes: [evidence-source-read] - grants: + permissions: - entity: record rowBoundaries: [] operations: [lookup] @@ -5986,7 +5986,7 @@ eventDestinations: {} "#; const INIT_DEV_CLIENTS: &[u8] = - br#"# Local callers for `bregctl dev`. Registry Mint, the local token issuer that + br#"# Local callers for `bregctl dev`. The owned ThunderID issuer that # `dev` starts beside the registry, registers each client below and issues it # short-lived tokens carrying these claims. One client binds each access profile # that `tests/journeys.yaml` uses, with the claims those journeys expect, so a @@ -6990,7 +6990,7 @@ fn explain_change_requests(compiled: &CompiledRegistry) -> serde_json::Result>(), - "reviewGrants": request.review_grants.iter().map(|grant| json!({ + "reviewPermissions": request.review_permissions.iter().map(|grant| json!({ "profile": grant.profile_id, "stage": grant.stage, "targetEntity": grant.target_entity_id, @@ -6999,12 +6999,12 @@ fn explain_change_requests(compiled: &CompiledRegistry) -> serde_json::Result>(), "rowBoundaries": grant.row_boundaries, })).collect::>(), - "applyGrants": request.apply_grants.iter().map(|grant| json!({ + "applyPermissions": request.apply_permissions.iter().map(|grant| json!({ "profile": grant.profile_id, "targetEntity": grant.target_entity_id, "rowBoundaries": grant.row_boundaries, })).collect::>(), - "presenceGrants": request.presence_grants.iter().map(|grant| json!({ + "presencePermissions": request.presence_permissions.iter().map(|grant| json!({ "profile": grant.profile_id, "targetEntity": grant.target_entity_id, "requestRowBoundaries": grant.request_row_boundaries, @@ -7040,7 +7040,7 @@ fn explain_change_requests(compiled: &CompiledRegistry) -> serde_json::Result>(), "eligibleRequestTypes": eligible, - "directWriteRestriction": "controlled operations are absent from ordinary grants and require compiled apply_request context", + "directWriteRestriction": "controlled operations are absent from ordinary permissions and require compiled apply_request context", })) }) .collect::>(); @@ -7247,7 +7247,7 @@ fn explain_actions(compiled: &CompiledRegistry) -> serde_json::Result { }) .map(|input| input.api_name.as_str()) .collect::>(), - "grants": action.grants.iter().map(|grant| json!({ + "permissions": action.permissions.iter().map(|grant| json!({ "profile": grant.profile_id, "default": grant.default, "anonymous": grant.anonymous, @@ -11480,7 +11480,7 @@ accessProfiles: - id: operator principalClaim: registry_principal requiredPurposes: [operations] - grants: + permissions: - entity: record rowBoundaries: [] operations: [create, get, list, patch] diff --git a/crates/registry-bregctl/tests/cli.rs b/crates/registry-bregctl/tests/cli.rs index ba4b49453b..f4638d101c 100644 --- a/crates/registry-bregctl/tests/cli.rs +++ b/crates/registry-bregctl/tests/cli.rs @@ -73,7 +73,7 @@ accessProfiles: default: true principalClaim: sub requiredScopes: [registry.read] - grants: + permissions: - entity: record rowBoundaries: [] operations: [get, list] @@ -465,7 +465,7 @@ fn access_review_example_explains_simulates_and_refuses_footguns_without_live_da "../../../products/breg/examples/access-review/registry.yaml" )) .unwrap(); - source["accessProfiles"][0]["grants"][0]["rowBoundaries"] = serde_json::json!([]); + source["accessProfiles"][0]["permissions"][0]["rowBoundaries"] = serde_json::json!([]); fs::write( project.path().join("registry.yaml"), serde_json::to_vec(&source).unwrap(), @@ -742,7 +742,7 @@ accessProfiles: principalClaim: private_claim_name requiredScopes: [registry:contact:register] requiredPurposes: [contact-registration] - grants: + permissions: - action: register-household-contact operations: [invoke] targets: @@ -754,7 +754,7 @@ accessProfiles: principalClaim: other_private_claim requiredScopes: [registry:contact:audit] requiredPurposes: [contact-audit] - grants: + permissions: - action: register-household-contact operations: [invoke] targets: @@ -1761,7 +1761,7 @@ entities: accessProfiles: - id: reader principalClaim: "registry_principal\n error forged.code forged" - grants: + permissions: - entity: record operations: [get] readableFields: [code] @@ -2782,7 +2782,7 @@ fn explain_access_includes_action_only_grants_and_target_reach() { .all(|entity| entity["profiles"].as_array().unwrap().is_empty())); let action = &explanation["actions"]["actions"][0]; assert_eq!(action["id"], "register-household-contact"); - let registrar = action["grants"] + let registrar = action["permissions"] .as_array() .unwrap() .iter() @@ -2858,9 +2858,9 @@ fn explain_actions_reports_compiled_effects_conditions_results_and_grants() { .iter() .any(|target| target["conditionRequired"] == true && target["source"]["input"]["apiName"] == "householdId")); - assert!(action["grants"] + assert!(action["permissions"] .as_array() - .expect("grants are listed") + .expect("permissions are listed") .iter() .any(|grant| grant["profile"] == "contact-registrar" && grant["results"] @@ -2964,7 +2964,7 @@ fn explain_change_requests_reports_compiled_effects_actions_and_controlled_write ); assert_eq!( explanation["controlledWrites"][0]["directWriteRestriction"], - "controlled operations are absent from ordinary grants and require compiled apply_request context" + "controlled operations are absent from ordinary permissions and require compiled apply_request context" ); assert_eq!( explanation["controlledWrites"][0]["eligibleRequestTypes"], @@ -3007,7 +3007,7 @@ entities: accessProfiles: - id: reader principalClaim: principal - grants: + permissions: - entity: typed-record rowBoundaries: [] operations: [list] @@ -3077,7 +3077,7 @@ accessProfiles: - id: map-reader default: true principalClaim: principal - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] @@ -3086,7 +3086,7 @@ accessProfiles: bbox: {maximumLongitudeSpanDegrees: 0.5, maximumLatitudeSpanDegrees: 0.25} - id: geometry-reader principalClaim: principal - grants: + permissions: - {entity: service-site, operations: [get, list], readableFields: [location], rowBoundaries: []} "#, ); diff --git a/crates/registry-bregctl/tests/dev_lifecycle.rs b/crates/registry-bregctl/tests/dev_lifecycle.rs index a497d59406..87015d90b5 100644 --- a/crates/registry-bregctl/tests/dev_lifecycle.rs +++ b/crates/registry-bregctl/tests/dev_lifecycle.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 -//! Installed-binary proof. Opt in after building breg, mint and bregctl; Docker +//! Installed-binary proof. Opt in after building breg and bregctl; Docker //! must be available. This creates and removes only its own synthetic database. +use base64::Engine as _; use serde_json::{json, Value}; use std::{ fs, @@ -166,16 +167,19 @@ fn poll_report(mut read: impl FnMut() -> Value, ready: impl Fn(&Value) -> bool) } #[test] -#[ignore = "requires matching installed breg/mint binaries and Docker; runs a retained local database"] +#[ignore = "requires matching installed breg/bregctl binaries and Docker; runs a retained local database"] fn installed_dev_receives_retries_replays_and_retains_authored_events() { + let binary = Path::new(env!("CARGO_BIN_EXE_bregctl")); + // The issuer bind-mounts this workspace into Docker. Keep the synthetic + // state beside the built binary instead of the host's possibly unshared + // platform TMPDIR (for example macOS /private/var/folders). let temporary = tempfile::Builder::new() .prefix("breg-native-events-test-") - .tempdir() + .tempdir_in(binary.parent().unwrap()) .unwrap(); fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700)).unwrap(); let parent = fs::canonicalize(temporary.keep()).unwrap(); let project = parent.join("registry"); - let binary = Path::new(env!("CARGO_BIN_EXE_bregctl")); let session = Session { project: project.clone(), path: std::env::join_paths([binary.parent().unwrap()]).unwrap(), @@ -232,7 +236,7 @@ seed: data: {code: synthetic-event-seed, label: Synthetic event seed, status: active} "#, ); - let [database_port, breg_port, mint_port] = free_ports(); + let [database_port, breg_port, issuer_port] = free_ports(); let first = session.report(session.dev(&[ "start", "--clients-file", @@ -241,8 +245,8 @@ seed: &database_port.to_string(), "--breg-port", &breg_port.to_string(), - "--mint-port", - &mint_port.to_string(), + "--issuer-port", + &issuer_port.to_string(), ])); assert_eq!(first["status"], "ready"); let dev = project.join(".breg/dev"); @@ -250,7 +254,7 @@ seed: let state: Value = serde_json::from_slice(&fs::read(&state_file).unwrap()).unwrap(); let webhook_port = u16::try_from(state["webhookPort"].as_u64().unwrap()).unwrap(); assert_ne!(webhook_port, 0); - assert!(![database_port, breg_port, mint_port].contains(&webhook_port)); + assert!(![database_port, breg_port, issuer_port].contains(&webhook_port)); let receiver_address = std::net::SocketAddr::from(([127, 0, 0, 1], webhook_port)); assert!(std::net::TcpStream::connect(receiver_address).is_ok()); let runtime_file = dev.join("runtime.yaml"); @@ -525,18 +529,18 @@ seed: } #[test] -#[ignore = "requires matching installed breg/mint binaries and Docker; runs a retained local database"] +#[ignore = "requires matching installed breg/bregctl binaries and Docker; runs a retained local database"] fn installed_dev_preserves_edits_and_recovers_failed_start_without_reseeding() { + let binary = Path::new(env!("CARGO_BIN_EXE_bregctl")); let temporary = tempfile::Builder::new() .prefix("breg-native-dev-test-") - .tempdir() + .tempdir_in(binary.parent().unwrap()) .unwrap(); fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700)).unwrap(); let parent = fs::canonicalize(temporary.keep()).unwrap(); let project = parent.join("registry"); - let binary = Path::new(env!("CARGO_BIN_EXE_bregctl")); // Only the binaries built beside bregctl serve this session: an installed - // breg or mint from an earlier package format must not be reachable, and + // breg from an earlier package format must not be reachable, and // Docker must be named explicitly rather than found. let session = Session { project: project.clone(), @@ -585,7 +589,7 @@ seed: ); // A service that exits before readiness causes owned child/container // cleanup. The same persisted keys and database can then start normally. - let [database_port, breg_port, mint_port] = free_ports(); + let [database_port, breg_port, issuer_port] = free_ports(); let origin = format!("http://127.0.0.1:{breg_port}"); let failed = session.dev(&[ "start", @@ -595,9 +599,9 @@ seed: &database_port.to_string(), "--breg-port", &breg_port.to_string(), - "--mint-port", - &mint_port.to_string(), - "--mint-bin", + "--issuer-port", + &issuer_port.to_string(), + "--breg-bin", "/usr/bin/false", ]); assert!(!failed.status.success()); @@ -629,8 +633,8 @@ seed: &database_port.to_string(), "--breg-port", &breg_port.to_string(), - "--mint-port", - &mint_port.to_string(), + "--issuer-port", + &issuer_port.to_string(), "--breg-bin", shim.to_str().unwrap(), ]); @@ -648,6 +652,50 @@ seed: let first = session.start(); assert_eq!(first["status"], "ready"); assert_eq!(first["bregUrl"], origin); + // Public source handoff copies only the selected private pair and reports + // the exact OAuth request parameters a separate Evidence process needs. + let handoff = parent.join("source-credentials"); + fs::create_dir(&handoff).unwrap(); + fs::set_permissions(&handoff, fs::Permissions::from_mode(0o700)).unwrap(); + let client_id = handoff.join("client-id"); + let assertion_key = handoff.join("assertion-key.jwk"); + let exported_client = session.success(&[ + "dev", + "export-client", + project.to_str().unwrap(), + "--client", + "source", + "--client-id-file", + client_id.to_str().unwrap(), + "--assertion-key-file", + assertion_key.to_str().unwrap(), + ]); + assert_eq!( + exported_client["issuer"], + format!("http://127.0.0.1:{issuer_port}") + ); + assert_eq!(exported_client["tokenEndpoint"], first["tokenEndpoint"]); + assert_eq!( + exported_client["clientAssertionAudience"], + exported_client["issuer"] + ); + assert_eq!(exported_client["resource"], first["audience"]); + assert_eq!( + exported_client["scopes"], + json!(["registry:evidence:lookup"]) + ); + assert_eq!(fs::read(&client_id).unwrap(), b"source"); + assert_eq!( + fs::read(&assertion_key).unwrap(), + fs::read(project.join(".breg/dev/credentials/source/assertion-key.jwk")).unwrap() + ); + assert_eq!( + fs::metadata(&assertion_key).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(!serde_json::to_string(&exported_client) + .unwrap() + .contains("\"d\":")); // A client token lives 300 seconds, less than the worst case a first // start may spend on its child and readiness deadlines before it seeds. // Every token must therefore be minted after the rehearsal, the built @@ -977,14 +1025,6 @@ seed: "--runtime-config", project.join(".breg/dev/runtime.yaml").to_str().unwrap(), ]); - assert!(Command::new("mint") - .env("PATH", &session.path) - .args(["verify-audit", "--config"]) - .arg(project.join(".breg/dev/mint/mint.yaml")) - .output() - .unwrap() - .status - .success()); session.remove(); assert_eq!( docker_line(&[ @@ -1063,3 +1103,147 @@ seed: std::mem::forget(session); fs::remove_dir_all(parent).unwrap(); } + +#[test] +#[ignore = "requires matching installed breg/bregctl binaries and Docker; runs a retained local database"] +fn installed_dev_prepares_a_native_source_successor_with_retained_records() { + let binary = Path::new(env!("CARGO_BIN_EXE_bregctl")); + let temporary = tempfile::Builder::new() + .prefix("breg-native-source-test-") + .tempdir_in(binary.parent().unwrap()) + .unwrap(); + fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let parent = fs::canonicalize(temporary.keep()).unwrap(); + let project = parent.join("registry"); + let session = Session { + project: project.clone(), + path: std::env::join_paths([binary.parent().unwrap()]).unwrap(), + docker: installed("docker"), + }; + session.success(&["init", project.to_str().unwrap()]); + let client_file = project.join("dev-clients.yaml"); + let mut clients = fs::read(&client_file).unwrap(); + clients.extend_from_slice( + br#" +seed: + - id: source-successor-seed + client: operator + entity: record + accessProfile: operator + data: {code: synthetic-source-successor, label: Synthetic source successor, status: active} +"#, + ); + write(&client_file, &clients); + let [database_port, breg_port, issuer_port] = free_ports(); + let first = session.report(session.dev(&[ + "start", + "--database-port", + &database_port.to_string(), + "--breg-port", + &breg_port.to_string(), + "--issuer-port", + &issuer_port.to_string(), + ])); + assert_eq!(first["status"], "ready"); + assert_eq!(first["packageSequence"], 1); + session.stop(); + + let source_args = [ + "dev", + "prepare-source", + project.to_str().unwrap(), + "--entity", + "record", + "--selector-field", + "code", + "--readable-fields", + "status", + "--all-records", + "--client", + "source-reader", + "--access-profile", + "source-reader", + "--selector-profile", + "by-code-source-reader", + ]; + let preview = session.success(&source_args); + assert_eq!(preview["status"], "preview"); + assert_eq!(preview["packageSequence"], 2); + let mut apply = source_args.to_vec(); + apply.push("--apply"); + let prepared = session.success(&apply); + assert_eq!(prepared["status"], "prepared"); + let restarted = session.start(); + assert_eq!(restarted["status"], "ready"); + assert_eq!(restarted["packageSequence"], 2); + assert_ne!(restarted["packageRevision"], first["packageRevision"]); + + let token = fs::read_to_string(project.join(".breg/dev/secrets/source-reader-token")).unwrap(); + let payload = token.split('.').nth(1).expect("issued JWT payload"); + let claims: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .unwrap(), + ) + .unwrap(); + assert_eq!(claims["scope"], "registry:source-reader:lookup"); + assert_eq!(claims["registry_principal"], "source-reader"); + + let export = parent.join("source-export"); + session.success(&[ + "generate", + "evidence-source", + project.to_str().unwrap(), + "--access-profile", + "source-reader", + "--entity", + "record", + "--selector", + "by-code-source-reader", + "--fields", + "status", + "--source-id", + "source-reader", + "--connection", + "registry", + "--output", + export.to_str().unwrap(), + ]); + let source: Value = + serde_norway::from_slice(&fs::read(export.join("sources/source-reader.yaml")).unwrap()) + .unwrap(); + let request = &source["request"]; + let select = request["projection"] + .as_array() + .unwrap() + .iter() + .map(|pointer| pointer.as_str().unwrap().rsplit('/').next().unwrap()) + .collect::>() + .join(","); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let mut request_builder = client + .post(format!("http://127.0.0.1:{breg_port}{}", request["path"].as_str().unwrap())) + .bearer_auth(&token) + .query(&[("accessProfile", "source-reader"), ("$select", select.as_str())]) + .json(&json!({"selector":"by-code-source-reader","values":{"code":"synthetic-source-successor"}})); + for header in request["fixedHeaders"].as_array().unwrap() { + request_builder = request_builder.header( + header["name"].as_str().unwrap(), + header["value"].as_str().unwrap(), + ); + } + let response = request_builder.send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + let found: Value = response.json().await.unwrap(); + assert_eq!(found["data"]["domainData"]["code"], "synthetic-source-successor"); + assert_eq!(found["data"]["domainData"]["status"], "active"); + }); + session.stop(); + std::mem::forget(session); + fs::remove_dir_all(parent).unwrap(); +} diff --git a/products/breg/ACCEPTANCE-JOURNEYS.md b/products/breg/ACCEPTANCE-JOURNEYS.md index c1e0168f2c..c4b3810a02 100644 --- a/products/breg/ACCEPTANCE-JOURNEYS.md +++ b/products/breg/ACCEPTANCE-JOURNEYS.md @@ -73,7 +73,7 @@ one governed surface, and each is executed by the test or script named beside it | `person-name-change-rhai` | Bounded Rhai planning inside a reviewed change request | `compiler_rhai_planner.rs` in the workspace test run and `postgres_rhai_planner.rs` in the postgres lane | | `person-registration-rhai` | Governed action handlers under `registry.action-handler/v1`, including their declared refusals | `postgres_action_handlers.rs` and `fixture_tooling.rs`, both in the postgres lane | | `farmer-landholding-evidence` | A governed action resolving a declared Evidence capability under the trial `registry.action-handler/v2` ABI | `action_evidence_compiler.rs` and `action_evidence_handler.rs` in the workspace test run, then `postgres_action_evidence.rs`, `postgres_action_evidence_targets.rs`, and `postgres_action_evidence_retention.rs` against a real `evidence` binary in the postgres lane | -| `spatial-service-sites` | Bounding-box read grants over spatial rows | `postgres_fixture_journeys.rs` in the postgres lane, and `quickstart/run.sh --spatial --smoke`, whose contract `test_quickstart.py` holds under `scripts/check-contracts.sh` | +| `spatial-service-sites` | Bounding-box read permissions over spatial rows | `postgres_fixture_journeys.rs` in the postgres lane, and `quickstart/run.sh --spatial --smoke`, whose contract `test_quickstart.py` holds under `scripts/check-contracts.sh` | | `registry-record-conformance` | The HTTP record contract across every configured record profile | `postgres_record_profile_conformance.rs` in the postgres lane | | `household-history` | Historical households loaded and queried across effective periods | `scripts/test-historical-workflow.sh` only, which no continuous integration job selects | | `issuer-portability` | An authority cutover from Registry Mint to a second issuer, with the old issuer rejected | the ignored `issuer_portability.rs` test, driven by `scripts/test-issuer-portability.py` only, which no continuous integration job selects | diff --git a/products/breg/CHANGE_REQUEST_EXAMPLES.md b/products/breg/CHANGE_REQUEST_EXAMPLES.md index d62664dcd5..fd6f48c584 100644 --- a/products/breg/CHANGE_REQUEST_EXAMPLES.md +++ b/products/breg/CHANGE_REQUEST_EXAMPLES.md @@ -3,7 +3,7 @@ For a first walkthrough, use the docs-site tutorial at `docs/site/src/content/docs/tutorials/review-registry-changes.mdx` in this source checkout. It runs an approval workflow, adapts a stage, and checks a refused -direct-write grant. This guide covers the broader example and operator details. +direct-write permission. This guide covers the broader example and operator details. Base Registry Engine change requests are ordinary product configuration. The compiler turns each request type into finite action routes, bounded action input schemas, @@ -140,7 +140,7 @@ CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ The explain output describes the compiled contract before running a database journey. It shows request types, action preconditions, controlled-write targets, -bounds, review stages, grants, and compiled effects. Read `registry.yaml` for the +bounds, review stages, permissions, and compiled effects. Read `registry.yaml` for the retention setting and a request's GET response for its current state and proposal metadata. @@ -224,7 +224,7 @@ The same pattern works for the household fixture with `--household-project` and the Rhai fixture with `--rhai-project`. A real adopter starting from one review stage must make the same three changes in their own project: add the second stage under `changeRequest.review.stages`, add -a grant whose `reviewStages` names that stage, and add a GET plus action step in +a permission whose `reviewStages` names that stage, and add a GET plus action step in the journey that uses the GET-discovered `request.actions[].ifMatch`, `proposalVersion`, and `effectDigest`. @@ -367,7 +367,7 @@ the same reason and other original action input with its idempotency key. GET exposes current decisions at `data.request.decisions` and retained decisions at `data.request.history.proposals[].decisions`. Each carries `stageId`, `kind`, `decidedAt`, and `reasonPresent`, with `reason` only when its text remains retained -and the selected profile permits it. The request grant defaults to +and the selected profile permits it. The request permission defaults to `readableRequestFields: [reason]`; an explicit empty list hides reason text without hiding the decision facts. Anonymous profiles never receive reason text. Request-detail erasure removes reason text while preserving the decision and @@ -431,11 +431,11 @@ cargo run --locked -p registry-bregctl -- \ ## Current native-reference submitter admission -A request grant may declare `submitterTargets: [target-entity-id]` to require +A request permission may declare `submitterTargets: [target-entity-id]` to require current ordinary GET authority on its fixed referenced target records. Each -named entity must have a GET grant in the same selected profile. The target's +named entity must have a GET permission in the same selected profile. The target's existing verified claim boundaries apply; profiles are never combined. The -explicit request grant activates admission and does not grant target writes. +explicit request permission activates admission and does not grant target writes. The compiler requires complete coverage of fixed existing native-reference effects, readable reference fields, manual application, and targets that are diff --git a/products/breg/DECISIONS.md b/products/breg/DECISIONS.md index 23f9e77c06..62e4ee1165 100644 --- a/products/breg/DECISIONS.md +++ b/products/breg/DECISIONS.md @@ -22,7 +22,7 @@ selected access profile and classification ceiling. - Selector profiles and relationship read paths are governed model entries, not runtime concepts. Selector values are exact inputs for a compiled lookup - and never grant authority. A read-path grant is confined to one configured + and never grant authority. A read-path permission is confined to one configured source, association entity, target, and target field capability set. - Registry Manifest remains the owner of standards-oriented metadata and DCAT rendering. Base Registry Engine emits a one-way, lossy Manifest source plus its diff --git a/products/breg/DEV.md b/products/breg/DEV.md index 186488ad3f..dce4d2949e 100644 --- a/products/breg/DEV.md +++ b/products/breg/DEV.md @@ -1,10 +1,12 @@ # Native local BReg lifecycle `bregctl dev` starts an existing, explicitly authored local registry using the -installed `breg` and `mint` binaries and Docker PostgreSQL. It needs no checkout, -Python launcher, shell script or OpenSSL installation. Mint is a local issuer -chosen by this development tool; an operated BReg runtime remains an independent -OAuth resource server. +installed `breg` binary, a source-pinned ThunderID image, and Docker PostgreSQL. +It needs no checkout, Python launcher, shell script or OpenSSL installation. +ThunderID is a local issuer chosen by this development tool; an operated BReg +runtime remains an independent OAuth resource server. This candidate cannot +start a retained Mint-backed v1 dev session. Keep its matching Mint-era tools +and data until a verified migration is available. Prepare the project with `bregctl init ./registry`. The generated package already declares `package.environment: local` and `package.sequence: 1`, and the @@ -23,32 +25,33 @@ The project path defaults to the current directory, as it does for every other `bregctl` command. A first start reads `dev-clients.yaml` inside the project; `--clients-file` names another clients file instead. `dev` and `dev start` both detach a resident supervisor. They return only after -PostgreSQL, Mint, schema-test rehearsal, package activation, BReg readiness and -explicit seed creation succeed. Default loopback ports are BReg `8090`, Mint +PostgreSQL, ThunderID, schema-test rehearsal, package activation, BReg readiness and +explicit seed creation succeed. Default loopback ports are BReg `8090`, issuer `8091` and PostgreSQL `55432`. Override them on the first start with -`--breg-port`, `--mint-port` and `--database-port`. A restart retains the +`--breg-port`, `--issuer-port` and `--database-port`. A restart retains the original ports and clients-file location. Conflicting ports are refused. The database runs the pinned image `postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, so an operator can check exactly what the supervisor pulls. Each supervised -prerequisite command may run for 120 seconds, and the owned database and each -started service have 45 seconds to answer as ready. A start that passes a -deadline fails, stops what it acquired and keeps its owner-only diagnostics. +prerequisite command may run for 120 seconds. The owned database and BReg have +45 seconds each to answer readiness, and ThunderID discovery has a 120-second wait. A start +that passes a deadline fails, stops what it acquired and keeps its owner-only +diagnostics. Before it inspects the owned container or launches the supervisor, a start runs -each resolved `breg`, `mint` and `docker` with `--version` and records what they -answer. `breg` and `mint` ship in the same release as `bregctl` and share a -package format, a token shape and a schema, so a `breg` or `mint` reporting -another version than this `bregctl` is refused by name: the refusal gives the -file that answered, the version it reported and the version `bregctl` reports. +each resolved `breg` and `docker` with `--version` and records what they +answer. `breg` ships in the same release as `bregctl`, so a `breg` reporting +another version is refused by name: the refusal gives the file that answered, +the version it reported and the version `bregctl` reports. Without that comparison the mismatch surfaces much later as a refused package or an unready database, which reads as a fault in the authored project. Docker belongs to no release of this stack and is never compared, and a prerequisite that reports no version at all still serves the session. No flag skips the -comparison: install `breg`, `mint` and `bregctl` from the same release, or put -the matching build first on `PATH`. `--breg-bin` and `--mint-bin` choose which -file is resolved, and the resolved file is the one compared. +comparison: install `breg` and `bregctl` from the same release, or put the +matching build first on `PATH`. `--breg-bin` chooses which file is resolved, +and that file is compared. `--mint-bin` and `--mint-port` are refused by this +candidate; they belong to the earlier Mint-based dev interface. `dev stop` keeps everything it created: the owned container, its named data volume, records, audit history, keys, credentials and the built package. Add @@ -65,9 +68,9 @@ Create a fresh project at package sequence 1 for a separate empty experiment. Use `--format json` to consume the status, URLs, audience, package revision, runtime configuration and private credential file references. Keys and access -tokens never appear in these reports. Tokens expire; the installed `mint token` -command can obtain a fresh token using a reported client ID file, key file and -token endpoint. Redirect that command's output to an owner-only file. +tokens never appear in these reports. Use `bregctl dev export-client` to obtain +the client ID, assertion key, issuer and token endpoint handoff; an OAuth client +can obtain a fresh token with those fixed values. Keep token output owner-only. ## Observe local events @@ -252,7 +255,7 @@ reviewed package lifecycle. The private `.breg/dev` directory records a random ownership identifier, exact Docker container ID, ports, captured authored closure, package revision, seed -checkpoints and, for each resolved `breg`, `mint` and `docker` prerequisite, the +checkpoints and, for each resolved `breg` and `docker` prerequisite, the fully resolved path of the file that ran and the version it reported. It contains generated configurations, separate database roles, local TLS material, credentials and bounded private diagnostic logs. The first start writes a @@ -267,13 +270,14 @@ identifiable and reclaimable once the container is gone. | --- | --- | | First start | Capture the authored closure, prepare private identities, create the owned database, run normal schema-test/package/apply/verify commands, then seed through authenticated HTTP. | | Already running | Return the existing ready session and credential references. | -| Stop, including repeated stop | Gracefully stop owned BReg and Mint children and stop the owned PostgreSQL container. Keep records, keys, package, seed checkpoints and audit history. | +| Stop, including repeated stop | Gracefully stop owned BReg and ThunderID, then stop the owned PostgreSQL container. Keep records, keys, package, seed checkpoints and audit history. | | Stop where no start ever ran | Refuse and name the absent session. Nothing is created, changed or removed, so a mistyped project path cannot read as a stopped session. | | Start after stop | Reuse the same container, database, and existing credentials. Preserve record edits; activate the explicitly prepared source successor when present. Obtain fresh short-lived tokens. | | Stop with `--remove`, including a repeated one | Stop as above, then remove the owned container and its named data volume, tolerating whatever an earlier reclamation already took. Discard records, audit history, event receipts and seed checkpoints. Keep keys, credentials, ports, clients and the built package. | | Start after `--remove` | At sequence 1, create an empty container and volume, activate the initial package, and replay authored seeds. A retained successor refuses before Docker because its predecessor records were removed; use a fresh project at sequence 1 for an empty experiment. | | Seed request committed before checkpoint | Replay the same permanent BReg idempotency reservation. The original create result is returned without creating or overwriting a record. | -| `breg` or `mint` from another release | Refuse before the owned container is inspected and before the supervisor launches, naming the file that answered, the version it reported and the version `bregctl` reports. Nothing is created, changed or removed. | +| `breg` from another release | Refuse before the owned container is inspected and before the supervisor launches, naming the file that answered, the version it reported and the version `bregctl` reports. Nothing is created, changed or removed. | +| Retained Mint-based v1 state | Refuse before v2 parsing or state mutation. Keep the matching Mint-era tools and data; this candidate has no retained issuer migration command. | | Partial start failure | Stop acquired service children and the owned container; retain private diagnostics and completed phases. Retry the same command after correcting the prerequisite. The separate schema-test database may be recreated for a failed rehearsal. | | Missing or mismatched owned container | Refuse. Never silently initialize an empty replacement or stop another container. | | Unreachable supervisor with an occupied service port | Refuse. Never signal a stored PID that could belong to another process. Inspect the process owning the port before recovery. | @@ -317,7 +321,7 @@ its code, the path it names, and its message, bounded to one sentence. The retained report log holds every later diagnostic. A refused journey step is reported as `test.step.failed` at `journeys[].steps[]`, carrying the fixture's own refusal sentence. A refused logical reference names its class: -a field the entity does not declare, a field the access profile grant does +a field the entity does not declare, a field the access profile permission does not make writable, a request body with no field, a step identifier that is not stable, a step naming both an entity and an action or neither, or a capture no earlier step declares. None of those sentences carries an authored diff --git a/products/breg/EVIDENCE.md b/products/breg/EVIDENCE.md index eaa05b268b..2da65fb7c0 100644 --- a/products/breg/EVIDENCE.md +++ b/products/breg/EVIDENCE.md @@ -15,7 +15,7 @@ import manifest or named connection. Start with an authored registry whose selected access profile already grants the lookup and the readable fields you intend to expose. The exporter reads the compiled model, including locked modules and derived SQL assets. It does -not change the registry or create an access grant. +not change the registry or create an access permission. ```sh mkdir exports diff --git a/products/breg/IMPLEMENTATION.md b/products/breg/IMPLEMENTATION.md index 18a6594cad..893858a01b 100644 --- a/products/breg/IMPLEMENTATION.md +++ b/products/breg/IMPLEMENTATION.md @@ -34,7 +34,7 @@ Version 1. | Governed Rhai action handlers under `registry.action-handler/v1`, with declared write slots, declared refusals, and input-only evaluation | Enforced | `immediate_action_handler.rs` and `postgres_action_handlers.rs`, over `acceptance/person-registration-rhai` | | Acceptance-time target requirements that check a stored field of an existing reference target | Enforced | `postgres_immediate_action_requirements.rs` and `postgres_registry_extensibility.rs`, over `fixtures/facility-registry-actions` | | Native persisted field patterns compiled to one validated PostgreSQL CHECK per field | Enforced | `native_patterns.rs`, `postgres_migration.rs`, and `schema_fingerprint_rehearsal.rs` | -| Current membership read boundaries on read grants, enforced by generated `SECURITY INVOKER` functions and forced row-level security | Enforced | `membership_access.rs` and `postgres_membership_access.rs`, over `fixtures/organization-membership-access` | +| Current membership read boundaries on read permissions, enforced by generated `SECURITY INVOKER` functions and forced row-level security | Enforced | `membership_access.rs` and `postgres_membership_access.rs`, over `fixtures/organization-membership-access` | | Change-request `submitterTargets`, rechecking current same-profile target authority under a transaction-scoped lock at create, draft patch, submit, revise, and replay | Enforced | `compiler_submitter_targets.rs` and `support/submitter_targets.rs`, over `starters/professional-licences/core` | | Conditional Evidence in Rhai under `registry.action-handler/v2`, calling `evidence::resolve` inside a governed action | Trial | `action_evidence_compiler.rs` and `postgres_action_evidence.rs`, over `acceptance/farmer-landholding-evidence` | | Protected evidence-use retention and the operator command `bregctl evidence-retention erase-expired` | Trial | `postgres_action_evidence_retention.rs` and `crates/registry-bregctl/tests/evidence_retention.rs` | diff --git a/products/breg/README.md b/products/breg/README.md index 2a79ba3f1b..e778279047 100644 --- a/products/breg/README.md +++ b/products/breg/README.md @@ -240,6 +240,8 @@ while the public-binary adopter workflow proves signed activation, authenticated data access, an additive upgrade, failure recovery, and unchanged server bytes for the asset project. See [change-request examples](CHANGE_REQUEST_EXAMPLES.md) for the approval workflows. +For bounded institutional-agent authority and current-status checks, see +[task grants for governed writes](TASK_GRANTS.md). The separate `household-history` fixture proves correction batches and retained snapshot answers through the same compiler and runtime. The additional `spatial-service-sites` project covers governed Point queries diff --git a/products/breg/SPATIAL-QUERIES.md b/products/breg/SPATIAL-QUERIES.md index 56c8b98cd0..0f2906275f 100644 --- a/products/breg/SPATIAL-QUERIES.md +++ b/products/breg/SPATIAL-QUERIES.md @@ -84,7 +84,7 @@ limit bounds released response bytes, not total database or process memory. The in-process `/v1/gis` adapter serves the QGIS OAPIF provider. Its collection IDs are `entity-id.profile-id`, so changing a profile cannot silently select -another profile's authority. Only direct list grants with readable primary +another profile's authority. Only direct list permissions with readable primary geometry and explicit bbox capability are advertised. GIS deployments require an operator-configured origin: diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md new file mode 100644 index 0000000000..d20a8c2072 --- /dev/null +++ b/products/breg/TASK_GRANTS.md @@ -0,0 +1,91 @@ +# Task grants for governed writes + +A Casework task grant gives an institutional agent bounded authority under its +own principal. It does not make the agent the human who approved the task. +BREG verifies the exchanged access token and selected access profile, then +checks current Casework status before each new governed mutation. Task agents +use change-request drafts and lifecycle operations; a task grant does not +authorize direct changes to the target records. + +The authored access profile selects `actorKind: agent`, exact +`requesterClients`, `requiredPurposes`, and a `taskGrant` containing `authority` +and `sourceIssuer`. The token must match that profile, BREG's configured audience, +and the compiled collection and operation bounds. A task token cannot fall back +to a standing access profile. Ordinary profiles retain their own authority. + +An authored `permissions` entry is the Registry's governed ceiling for a +profile. A delegated `taskGrant` is signed, short-lived authority for one task +inside that ceiling. It never adds an operation or field. BREG requires the +grant's complete BREG permission bounds to equal the profile's compiled +collections and operations. Wider, narrower, partial, and wrong-resource +bounds are refused. + +Standing native citizen agents use the same `actorKind: agent` and +`requesterClients` binding without a `taskGrant`. Configure the exact native +actor identity for each such client under `authentication.authorityClaims`: + +```yaml +authentication: + authorityClaims: + principal: sub + purpose: registry_purpose + trustedActors: + citizen-self-service-agent: 00000000-0000-4000-8000-000000000001 +``` + +The access token keeps the citizen in `sub`. Its `act.sub` must equal the +configured actor for the verified `azp` or `client_id`; BREG does not accept a +caller-supplied actor alias. Custom contextual claim names, when needed for an +existing issuer, are configured together in the closed `contextual` object. + +## Configure current status + +Configure BREG's runtime with one entry for each trusted authority and original +source issuer used by its task profiles: + +```yaml +taskGrantStatus: + - authority: https://casework.example.gov/tasks + sourceIssuer: https://casework.example.gov + baseUrl: https://casework.example.gov + tokenEndpoint: https://identity.example.gov/oauth2/token + clientId: breg-task-status + privateKeyRef: secret:file/breg-task-status-private-jwk + caseworkResource: urn:casework:case-management +``` + +Register this client for client credentials with the exact Casework resource and +`casework:grants:status` scope. Register the same client in Casework's status +client mapping for this BREG resource. BREG supplies its configured OIDC audience +as that resource; neither a caller nor the grant chooses an outbound endpoint. +The private JWK must include its registered key identifier. `caBundleRef` can +refer to a private CA PEM bundle for both outbound connections. Production +endpoints use HTTPS; loopback HTTP is available for local development. + +Startup refuses a task profile without its configured authority/source-issuer +mapping. A failed status request refuses the mutation. No positive status result +is cached. BREG compares all retained authorization fields, including the +original principal, client, resource, purpose, bounds, subjects, and deadline. + +## Approval, retries, and expiry + +Submission freezes the original task authority alongside the proposal version +and its immutable effects. A later human reviewer remains a separate actor. +Approval and application check the original grant, including automatic +application. A database concurrency retry makes a fresh status check. + +An exact completed retry recovers the existing receipt under current disclosure +authority. It does not perform a new mutation or require a new positive status +check. Reusing the idempotency key with a different grant conflicts. An expired +or revoked task requires an explicit new authorized submission; refreshing an +access token cannot extend the original grant deadline. + +Status refusal returns a failed precondition; status service failure returns +unavailable. Human rejection and owner cancellation keep their existing +permissions. These actions do not require the old submitter grant to remain +active. Read cursors and representation ETags do not incorporate grant IDs. + +The bounded status check runs while the proposal is locked. Casework revocation +can still occur between that check and the local commit. BREG and Casework do +not share a distributed transaction. Proposal-detail erasure also removes its +retained task subjects, under the existing operator retention boundary. diff --git a/products/breg/acceptance/asset-site-placement-change-requests/registry.yaml b/products/breg/acceptance/asset-site-placement-change-requests/registry.yaml index 31951757c4..c74d437e2b 100644 --- a/products/breg/acceptance/asset-site-placement-change-requests/registry.yaml +++ b/products/breg/acceptance/asset-site-placement-change-requests/registry.yaml @@ -115,7 +115,7 @@ accessProfiles: default: true principalClaim: registry_principal requiredPurposes: [asset-management] - grants: + permissions: - {entity: asset-item, operations: [create, get, list, patch, batch], readableFields: [asset-code, label, asset-class], writableFields: [asset-code, label, asset-class], rowBoundaries: []} - {entity: asset-site, operations: [create, get, list, patch], readableFields: [site-code, label], writableFields: [site-code, label], rowBoundaries: []} - entity: asset-placement @@ -129,7 +129,7 @@ accessProfiles: - id: site-planner principalClaim: registry_principal requiredPurposes: [site-planning] - grants: + permissions: - {entity: asset-item, operations: [get, list], readableFields: [asset-code, label], filterableFields: [asset-code], rowBoundaries: []} - {entity: asset-site, operations: [get, list], readableFields: [site-code, label], filterableFields: [site-code], rowBoundaries: []} - entity: asset-placement @@ -144,7 +144,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:submit] requiredPurposes: [asset-correction] - grants: + permissions: - entity: asset-item rowBoundaries: [] operations: [get] @@ -168,7 +168,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:review] requiredPurposes: [asset-correction-review] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, list, approve_request, reject_request, request_revision] @@ -181,7 +181,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:supervise] requiredPurposes: [asset-correction-review] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, approve_request, reject_request, request_revision] @@ -194,7 +194,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:apply] requiredPurposes: [asset-correction-apply] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, apply_request] diff --git a/products/breg/acceptance/asset-site-placement/registry.yaml b/products/breg/acceptance/asset-site-placement/registry.yaml index 7b44208911..72ad6939be 100644 --- a/products/breg/acceptance/asset-site-placement/registry.yaml +++ b/products/breg/acceptance/asset-site-placement/registry.yaml @@ -149,7 +149,7 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: - asset-management - grants: + permissions: - entity: asset-item rowBoundaries: [] operations: @@ -214,7 +214,7 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: - site-planning - grants: + permissions: - entity: asset-item rowBoundaries: [] operations: diff --git a/products/breg/acceptance/business-establishments/registry.yaml b/products/breg/acceptance/business-establishments/registry.yaml index bb6a568e99..35c57e9932 100644 --- a/products/breg/acceptance/business-establishments/registry.yaml +++ b/products/breg/acceptance/business-establishments/registry.yaml @@ -173,7 +173,7 @@ accessProfiles: - registry:business:operate requiredPurposes: - business-administration - grants: + permissions: - entity: establishment rowBoundaries: [] operations: @@ -298,7 +298,7 @@ accessProfiles: - registry:business:view requiredPurposes: - business-view - grants: + permissions: - entity: business operations: - get diff --git a/products/breg/acceptance/business/registry.yaml b/products/breg/acceptance/business/registry.yaml index 7be9afc962..d18c04be84 100644 --- a/products/breg/acceptance/business/registry.yaml +++ b/products/breg/acceptance/business/registry.yaml @@ -217,7 +217,7 @@ accessProfiles: - id: public-register default: true anonymous: true - grants: + permissions: - entity: legal-entity rowBoundaries: [] operations: @@ -255,7 +255,7 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: - business-registry - grants: + permissions: - entity: legal-entity rowBoundaries: [] operations: diff --git a/products/breg/acceptance/facility/registry.yaml b/products/breg/acceptance/facility/registry.yaml index 0e38a28579..abc2378b24 100644 --- a/products/breg/acceptance/facility/registry.yaml +++ b/products/breg/acceptance/facility/registry.yaml @@ -259,7 +259,7 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: - facility-registry - grants: + permissions: - entity: facility operations: - create diff --git a/products/breg/acceptance/farmer-landholding-evidence/README.md b/products/breg/acceptance/farmer-landholding-evidence/README.md index cea606b20b..54a875abde 100644 --- a/products/breg/acceptance/farmer-landholding-evidence/README.md +++ b/products/breg/acceptance/farmer-landholding-evidence/README.md @@ -37,8 +37,8 @@ showing that declared capabilities are optional. It cannot create a landholding. Both status and category are in the registrar's reviewed processing ceiling; `include-category` selects optional disclosure within that ceiling and grants no -additional authority. There are no CRUD create grants, other mutation actions -or reviewed-change grants that can bypass the registration procedure. The reader +additional authority. There are no CRUD create permissions, other mutation actions +or reviewed-change permissions that can bypass the registration procedure. The reader can only read. Local unique parcel constraints still apply to every writer. The imported contract is synthetic and offline. Its fixed revision is a test diff --git a/products/breg/acceptance/farmer-landholding-evidence/registry.yaml b/products/breg/acceptance/farmer-landholding-evidence/registry.yaml index 00ed41874f..547e05eb62 100644 --- a/products/breg/acceptance/farmer-landholding-evidence/registry.yaml +++ b/products/breg/acceptance/farmer-landholding-evidence/registry.yaml @@ -206,7 +206,7 @@ accessProfiles: - registry:landholding:register requiredPurposes: - land-registration - grants: + permissions: - action: register-landholding operations: - invoke @@ -230,7 +230,7 @@ accessProfiles: - registry:landholding:read requiredPurposes: - land-registration-audit - grants: + permissions: - entity: landholding operations: - get diff --git a/products/breg/acceptance/household-history/registry.yaml b/products/breg/acceptance/household-history/registry.yaml index 6e9c14f215..73c6a3863c 100644 --- a/products/breg/acceptance/household-history/registry.yaml +++ b/products/breg/acceptance/household-history/registry.yaml @@ -66,13 +66,13 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: [history-maintenance] requiredScopes: [history-maintain] - grants: + permissions: - {entity: membership-record, operations: [create, get, list, patch, batch, snapshot], readableFields: [subject, group, valid-from, valid-to, source-reference], writableFields: [subject, group, valid-from, valid-to, source-reference], filterableFields: [subject, group, valid-from, valid-to], rowBoundaries: []} - id: eligibility-consumer principalClaim: registry_principal requiredPurposes: [eligibility-evaluation] requiredScopes: [eligibility-read] - grants: + permissions: - {entity: membership-record, operations: [list, snapshot], readableFields: [subject, group, valid-from, valid-to], filterableFields: [subject, group, valid-from, valid-to], rowBoundaries: []} vocabularies: - {id: membership-group, values: [A, B]} diff --git a/products/breg/acceptance/inspection/registry.yaml b/products/breg/acceptance/inspection/registry.yaml index 6a02782563..ad728dc12c 100644 --- a/products/breg/acceptance/inspection/registry.yaml +++ b/products/breg/acceptance/inspection/registry.yaml @@ -201,7 +201,7 @@ accessProfiles: principalClaim: registry_principal requiredPurposes: - facility-inspection - grants: + permissions: - entity: inspection rowBoundaries: [] operations: diff --git a/products/breg/acceptance/issuer-portability/registry.yaml b/products/breg/acceptance/issuer-portability/registry.yaml index 473229e0e1..ad88fd18ba 100644 --- a/products/breg/acceptance/issuer-portability/registry.yaml +++ b/products/breg/acceptance/issuer-portability/registry.yaml @@ -21,7 +21,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry.read] requiredPurposes: [registry-administration] - grants: + permissions: - entity: record operations: [get] readableFields: [label] diff --git a/products/breg/acceptance/person-name-change-rhai/registry.yaml b/products/breg/acceptance/person-name-change-rhai/registry.yaml index 58c88817a9..ccc3d38ed4 100644 --- a/products/breg/acceptance/person-name-change-rhai/registry.yaml +++ b/products/breg/acceptance/person-name-change-rhai/registry.yaml @@ -82,14 +82,14 @@ accessProfiles: default: true principalClaim: registry_principal requiredPurposes: [person-maintenance] - grants: + permissions: - {entity: person, operations: [create, get, list], readableFields: [person-code, display-name], writableFields: [person-code, display-name], rowBoundaries: []} - id: name-change-submitter default: true principalClaim: registry_principal requiredScopes: ["registry:person-name:submit"] requiredPurposes: [person-name-change] - grants: + permissions: - entity: person-name-change-request rowBoundaries: [] operations: [create, get, submit_request, apply_request] @@ -101,7 +101,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: ["registry:person-name:apply-assisted"] requiredPurposes: [person-name-apply] - grants: + permissions: - entity: person-name-change-request rowBoundaries: [] operations: [get, list, apply_request] diff --git a/products/breg/acceptance/person-registration-rhai/README.md b/products/breg/acceptance/person-registration-rhai/README.md index 38748f5a33..47f4b2eb5e 100644 --- a/products/breg/acceptance/person-registration-rhai/README.md +++ b/products/breg/acceptance/person-registration-rhai/README.md @@ -131,7 +131,7 @@ values, against the explicit synthetic expectation file. Default reports expose identity, field names, shapes and counts, without dumping input values, record IDs or source. A mismatch reports a path without disclosing the values. These checks prove calculation and decoding, not PostgreSQL patterns, target -existence, grants, locks, conditions, atomicity or delivery. +existence, permissions, locks, conditions, atomicity or delivery. `check` reports that the identifier's native pattern is unverified offline; the PostgreSQL journey below validates its syntax and enforcement. diff --git a/products/breg/acceptance/person-registration-rhai/registry.yaml b/products/breg/acceptance/person-registration-rhai/registry.yaml index c691d28538..cdfaa3b52c 100644 --- a/products/breg/acceptance/person-registration-rhai/registry.yaml +++ b/products/breg/acceptance/person-registration-rhai/registry.yaml @@ -127,7 +127,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:person:register] requiredPurposes: [person-registration] - grants: + permissions: - action: register-person operations: [invoke] targets: [{entity: person, rowBoundaries: []}] @@ -141,7 +141,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:person:read] requiredPurposes: [person-registration-audit] - grants: + permissions: - {entity: person, operations: [get], readableFields: [identifier, display-name], rowBoundaries: []} - {entity: registration, operations: [get], readableFields: [registration-code, person, register], rowBoundaries: []} - {entity: register, operations: [get], readableFields: [register-code, active, last-person], rowBoundaries: []} @@ -149,6 +149,6 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:person:manage] requiredPurposes: [person-maintenance] - grants: + permissions: - {entity: person, operations: [create, get, patch], readableFields: [identifier, display-name], writableFields: [identifier, display-name], rowBoundaries: []} - {entity: register, operations: [create, get, patch], readableFields: [register-code, active, last-person], writableFields: [register-code, active, last-person], rowBoundaries: []} diff --git a/products/breg/acceptance/publicschema-household-change-requests/registry.yaml b/products/breg/acceptance/publicschema-household-change-requests/registry.yaml index 3eb274004e..bef3488d79 100644 --- a/products/breg/acceptance/publicschema-household-change-requests/registry.yaml +++ b/products/breg/acceptance/publicschema-household-change-requests/registry.yaml @@ -188,7 +188,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:operate] requiredPurposes: [household-administration] - grants: + permissions: - {entity: person, operations: [get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], filterableFields: [person-code, person-sex, residency-status], sortableFields: [person-code], rowBoundaries: []} - entity: household rowBoundaries: [] @@ -214,7 +214,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household-contact:submit] requiredPurposes: [household-contact-registration] - grants: + permissions: - entity: register-household-contact-request rowBoundaries: [] operations: [create, get, patch, submit_request, revise_request, cancel_request] @@ -225,7 +225,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household-contact:review] requiredPurposes: [household-contact-review] - grants: + permissions: - entity: register-household-contact-request rowBoundaries: [] operations: [get, list, approve_request, reject_request, request_revision] @@ -240,7 +240,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household-contact:supervise] requiredPurposes: [household-contact-review] - grants: + permissions: - entity: register-household-contact-request rowBoundaries: [] operations: [get, approve_request, reject_request, request_revision] @@ -255,7 +255,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household-contact:apply] requiredPurposes: [household-contact-apply] - grants: + permissions: - entity: register-household-contact-request rowBoundaries: [] operations: [get, apply_request] @@ -268,7 +268,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:view] requiredPurposes: [household-view] - grants: + permissions: - entity: household operations: [get, lookup] readableFields: [household-code, local-household-number, household-name, administrative-area, household-type] diff --git a/products/breg/acceptance/publicschema-household/registry.yaml b/products/breg/acceptance/publicschema-household/registry.yaml index 5b0c51e33c..258ce88812 100644 --- a/products/breg/acceptance/publicschema-household/registry.yaml +++ b/products/breg/acceptance/publicschema-household/registry.yaml @@ -140,7 +140,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:operate] requiredPurposes: [household-administration] - grants: + permissions: - {entity: person, operations: [create, get, list, patch], readableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], writableFields: [person-code, legal-name, family-name, date-of-birth, person-sex, residency-status, preferred-language], filterableFields: [person-code, person-sex, residency-status], sortableFields: [person-code], rowBoundaries: []} - entity: household rowBoundaries: [] @@ -164,7 +164,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:view] requiredPurposes: [household-view] - grants: + permissions: - entity: household operations: [get, lookup] readableFields: [household-code, local-household-number, household-name, administrative-area, household-type] diff --git a/products/breg/acceptance/registry-record-conformance/registry.yaml b/products/breg/acceptance/registry-record-conformance/registry.yaml index ccda59e0fa..91db6d01ce 100644 --- a/products/breg/acceptance/registry-record-conformance/registry.yaml +++ b/products/breg/acceptance/registry-record-conformance/registry.yaml @@ -69,7 +69,7 @@ accessProfiles: - id: public-reader default: true anonymous: true - grants: + permissions: - entity: public-unit rowBoundaries: [] operations: [get, list] @@ -79,7 +79,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [records:protected:read] requiredPurposes: [bounded-read] - grants: + permissions: - entity: protected-unit rowBoundaries: [] operations: [get, list] @@ -89,7 +89,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [records:fixture:write] requiredPurposes: [fixture-setup] - grants: + permissions: - entity: public-unit rowBoundaries: [] operations: [create] diff --git a/products/breg/acceptance/request-attachments/registry.yaml b/products/breg/acceptance/request-attachments/registry.yaml index 31624ae1b0..c1eb33f0cb 100644 --- a/products/breg/acceptance/request-attachments/registry.yaml +++ b/products/breg/acceptance/request-attachments/registry.yaml @@ -103,7 +103,7 @@ "requiredScopes": [ "attachments:operate" ], - "grants": [ + "permissions": [ { "entity": "record", "operations": [ @@ -130,7 +130,7 @@ "requiredScopes": [ "attachments:submit" ], - "grants": [ + "permissions": [ { "entity": "correction-request", "operations": [ @@ -165,7 +165,7 @@ "requiredScopes": [ "attachments:submit" ], - "grants": [ + "permissions": [ { "entity": "correction-request", "operations": [ @@ -200,7 +200,7 @@ "requiredScopes": [ "attachments:review" ], - "grants": [ + "permissions": [ { "entity": "correction-request", "operations": [ @@ -249,7 +249,7 @@ "requiredScopes": [ "attachments:apply" ], - "grants": [ + "permissions": [ { "entity": "correction-request", "operations": [ diff --git a/products/breg/acceptance/spatial-service-sites/registry.yaml b/products/breg/acceptance/spatial-service-sites/registry.yaml index 61fd69e954..4b49a1fa46 100644 --- a/products/breg/acceptance/spatial-service-sites/registry.yaml +++ b/products/breg/acceptance/spatial-service-sites/registry.yaml @@ -85,7 +85,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [service-sites:seed] requiredPurposes: [service-site-administration] - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [create, get, list, patch, batch] @@ -96,7 +96,7 @@ accessProfiles: allowCount: true - id: map-reader anonymous: true - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] @@ -112,7 +112,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [service-sites:map.read] requiredPurposes: [service-site-map] - grants: + permissions: - entity: service-site operations: [get, list] readableFields: [native-id, site-code, label, zone, service-kind, location, map-label] @@ -127,7 +127,7 @@ accessProfiles: maximumLatitudeSpanDegrees: 0.20 - id: directory-reader anonymous: true - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] @@ -139,7 +139,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [service-sites:directory.read] requiredPurposes: [service-site-directory] - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get, list] @@ -150,7 +150,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [service-sites:site.read] requiredPurposes: [service-site-map] - grants: + permissions: - entity: service-site rowBoundaries: [] operations: [get] diff --git a/products/breg/contracts/acceptance-scenario-matrix.yaml b/products/breg/contracts/acceptance-scenario-matrix.yaml index d192d4706e..87322e960b 100644 --- a/products/breg/contracts/acceptance-scenario-matrix.yaml +++ b/products/breg/contracts/acceptance-scenario-matrix.yaml @@ -85,7 +85,7 @@ scenarios: state: enforced domain: asset-registration-actions fixture: fixtures/asset-registration-actions - doneWhen: "Configured create-only immediate action journeys run through the authenticated PostgreSQL fixture runner without CRUD grants." + doneWhen: "Configured create-only immediate action journeys run through the authenticated PostgreSQL fixture runner without CRUD permissions." evidence: [{path: crates/registry-breg/tests/postgres_immediate_action_examples.rs, name: asset_registration_action_example_runs_through_authenticated_postgres_fixture_runner}] - id: BREG-J19 state: enforced diff --git a/products/breg/contracts/definition-of-done.yaml b/products/breg/contracts/definition-of-done.yaml index 07369b2f23..732c8798ea 100644 --- a/products/breg/contracts/definition-of-done.yaml +++ b/products/breg/contracts/definition-of-done.yaml @@ -70,8 +70,8 @@ requirements: - {id: BREG-V1-ACTION-HANDLERS, phase: W5, state: enforced, doneWhen: "Input-only Rhai handlers compute inside declared write slots, return exactly one declared refusal or effect set, and cannot widen compiled authority.", journeys: [BREG-J18, BREG-J19], evidence: [{path: crates/registry-breg/tests/immediate_action_handler.rs, name: handler_business_refusal_is_exclusive_bounded_and_governed}, {path: crates/registry-breg/tests/postgres_action_handlers.rs, name: action_handlers_compute_refuse_retry_recover_and_preserve_compiled_authority}]} - {id: BREG-V1-ACTION-REQUIREMENTS, phase: W5, state: enforced, doneWhen: "Acceptance-time target requirements hold the reference row lock until commit, refuse without values, and leave no effect, revision, event, or receipt behind.", journeys: [BREG-J08, BREG-J10], evidence: [{path: crates/registry-breg/tests/postgres_immediate_action_requirements.rs, name: action_requirements_are_acceptance_only_atomic_and_replayable}, {path: crates/registry-breg/tests/postgres_immediate_action_requirements.rs, name: action_requirements_hold_reference_lock_against_concurrent_inactivation}]} - {id: BREG-V1-NATIVE-PATTERNS, phase: W5, state: enforced, doneWhen: "A persisted field pattern installs one validated native CHECK, binds the schema fingerprint, and fails closed in maintenance with exact target recovery.", journeys: [BREG-J02, BREG-J15], evidence: [{path: crates/registry-breg/tests/native_patterns.rs, name: native_pattern_is_persisted_bounded_and_not_a_portable_schema_pattern}, {path: crates/registry-breg/tests/postgres_migration.rs, name: native_patterns_validate_empty_tables_existing_rows_and_exact_target_recovery}, {path: crates/registry-bregctl/tests/cli.rs, name: check_reports_native_patterns_as_unverified_until_postgres_schema_test}]} - - {id: BREG-V1-MEMBERSHIP-BOUNDARIES, phase: W5, state: enforced, doneWhen: "Membership-bounded read grants recheck live membership in PostgreSQL, hide their processing inputs, and refuse unenforced authority paths at compilation.", journeys: [BREG-J08, BREG-J09], evidence: [{path: crates/registry-breg/tests/membership_access.rs, name: membership_boundaries_refuse_unenforced_authority_paths}, {path: crates/registry-breg/tests/postgres_membership_access.rs, name: real_postgres_membership_reads_recheck_live_membership_and_hide_processing_inputs}]} - - {id: BREG-V1-SUBMITTER-TARGETS, phase: W5, state: enforced, doneWhen: "Change-request submitterTargets recheck current same-profile target authority under a transaction-scoped lock at create, patch, submit, revise, and replay, refuse an unreadable target, conceal denied token authority, and the compiler refuses a grant it cannot honour.", journeys: [BREG-J08, BREG-J09], evidence: [{path: crates/registry-breg/tests/compiler_submitter_targets.rs, name: native_reference_admission_requires_complete_manual_same_profile_authority}, {path: crates/registry-breg/tests/support/submitter_targets.rs, name: native_reference_submitter_admission_is_live_and_atomic}]} + - {id: BREG-V1-MEMBERSHIP-BOUNDARIES, phase: W5, state: enforced, doneWhen: "Membership-bounded read permissions recheck live membership in PostgreSQL, hide their processing inputs, and refuse unenforced authority paths at compilation.", journeys: [BREG-J08, BREG-J09], evidence: [{path: crates/registry-breg/tests/membership_access.rs, name: membership_boundaries_refuse_unenforced_authority_paths}, {path: crates/registry-breg/tests/postgres_membership_access.rs, name: real_postgres_membership_reads_recheck_live_membership_and_hide_processing_inputs}]} + - {id: BREG-V1-SUBMITTER-TARGETS, phase: W5, state: enforced, doneWhen: "Change-request submitterTargets recheck current same-profile target authority under a transaction-scoped lock at create, patch, submit, revise, and replay, refuse an unreadable target, conceal denied token authority, and the compiler refuses a permission it cannot honour.", journeys: [BREG-J08, BREG-J09], evidence: [{path: crates/registry-breg/tests/compiler_submitter_targets.rs, name: native_reference_admission_requires_complete_manual_same_profile_authority}, {path: crates/registry-breg/tests/support/submitter_targets.rs, name: native_reference_submitter_admission_is_live_and_atomic}]} - {id: BREG-V1-ACTION-EVIDENCE-TRIAL, phase: W5, state: partial, doneWhen: "A governed action resolves a declared Evidence capability offline and against a real signed service, then commits writes, audit, receipt, and protected evidence-use material atomically.", journeys: [BREG-J08, BREG-J10], gap: "The registry.action-handler/v2 ABI and its evidence::resolve helper remain a trial in products/breg/immediate-actions.md and products/breg/EVIDENCE.md; the authoring contract is not frozen for Version 1.", evidence: [{path: crates/registry-breg/tests/action_evidence_compiler.rs, name: refuses_origin_profile_output_ambiguity_and_budget_widening}, {path: crates/registry-breg/tests/postgres_action_evidence.rs, name: signed_evidence_actions_release_postgres_and_commit_atomic_transcripts}]} - {id: BREG-V1-EVIDENCE-RETENTION, phase: W5, state: partial, doneWhen: "Expired protected evidence-use material is erased only through the migration role, on a verified database, while the Registry transaction lock is held, and receipts stay replayable.", journeys: [BREG-J17], gap: "The retained scope exists only for the trial registry.action-handler/v2 ABI described in products/breg/immediate-actions.md, so its shape is not frozen for Version 1.", evidence: [{path: crates/registry-breg/tests/postgres_action_evidence_retention.rs, name: retention_serializes_activation_and_holds_identity_lock_through_deletion}, {path: crates/registry-bregctl/tests/evidence_retention.rs, name: invalid_runtime_configuration_reports_evidence_retention_recovery_without_values}]} - {id: BREG-V1-MODEL-DERIVATION, phase: W5, state: enforced, doneWhen: "An adopter derives a project from the pinned offline reference model by starter, selection file, or terminal selection, and the written closure compiles in both profiles.", journeys: [BREG-J17], evidence: [{path: crates/registry-bregctl/src/init_from_model/mod.rs, name: a_derived_project_is_written_and_compiles}, {path: crates/registry-bregctl/tests/cli.rs, name: init_from_publicschema_starter_writes_a_derived_project_that_checks_immediately}]} diff --git a/products/breg/contracts/security-invariant-matrix.yaml b/products/breg/contracts/security-invariant-matrix.yaml index 1111fd65a1..855bfe673a 100644 --- a/products/breg/contracts/security-invariant-matrix.yaml +++ b/products/breg/contracts/security-invariant-matrix.yaml @@ -20,10 +20,10 @@ invariants: - {id: BREG-SEC-17, state: enforced, targetWave: W3, threat: An encrypted cursor is replayed under a different authorized query context., enforcementPoint: fresh HTTP authorization plus authenticated cursor opening and PostgreSQL ReadPlan binding recomputation, refusal: "Reject before SQL when package, route, operation, profile, principal, purpose, row boundary, projection, filter, sort, temporal instant, page size, or expiry differs.", negativeId: BREG-NEG-17, negativeTest: {path: crates/registry-breg/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}} - {id: BREG-SEC-18, state: enforced, targetWave: W5, threat: A bulk request bypasses per-item authority or commits a valid prefix after a later item fails., enforcementPoint: configured Batch route and single-transaction mutation coordinator, refusal: "Refuse the complete request before record I/O when its bounds, operation, profile, or mutation mode is invalid; otherwise roll back every item and release nothing when any item or terminal component fails.", negativeId: BREG-NEG-18, negativeTest: {path: crates/registry-breg/tests/postgres_batch.rs, name: real_postgres_batch_is_bounded_authorized_atomic_and_exactly_replayable}} - {id: BREG-SEC-19, state: enforced, targetWave: W5, threat: "A malformed, private, ambiguous, denied, or wrong-algorithm operator-pinned JWKS selects unintended verification material or leaks key metadata.", enforcementPoint: static JWKS validation before verifier construction in production startup and schema-test execution, refusal: "Reject the complete bounded document unless it is a strict duplicate-free set of valid public keys exactly bound to the configured algorithm, signature use, verification operation, and kid policy.", negativeId: BREG-NEG-19, negativeTest: {path: crates/registry-breg/tests/runtime_config.rs, name: static_jwks_validation_refuses_unsafe_documents_value_free}} - - {id: BREG-SEC-20, state: enforced, targetWave: W3, threat: "A lookup selector accepts partial, extra, mistyped, caller-supplied claim values, queries unbounded rows, or otherwise becomes an enumeration oracle.", enforcementPoint: compiled selector grant bounded lookup request parser and two-row PostgreSQL limit, refusal: "Require the selector's exact configured field set and origin, query at most two rows, and collapse unknown, ungranted, missing-claim, zero-match, and multiple-match outcomes to one value-free unresolved response without audit cardinality.", negativeId: BREG-NEG-20, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free}} - - {id: BREG-SEC-21, state: enforced, targetWave: W3, threat: "A relationship traversal inherits direct target rights or widens its configured target projection, filtering, ordering, or count authority.", enforcementPoint: compiled named read-path grant and route-specific query plan, refusal: "Require an independently granted path and enforce only that path's target fields and query capabilities; conceal unknown and ungranted paths identically before record I/O.", negativeId: BREG-NEG-21, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: relationship_route_uses_path_grant_not_direct_target_rights}} + - {id: BREG-SEC-20, state: enforced, targetWave: W3, threat: "A lookup selector accepts partial, extra, mistyped, caller-supplied claim values, queries unbounded rows, or otherwise becomes an enumeration oracle.", enforcementPoint: compiled selector permission bounded lookup request parser and two-row PostgreSQL limit, refusal: "Require the selector's exact configured field set and origin, query at most two rows, and collapse unknown, ungranted, missing-claim, zero-match, and multiple-match outcomes to one value-free unresolved response without audit cardinality.", negativeId: BREG-NEG-20, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: lookup_body_exactness_origin_types_and_unresolved_equivalence_are_value_free}} + - {id: BREG-SEC-21, state: enforced, targetWave: W3, threat: "A relationship traversal inherits direct target rights or widens its configured target projection, filtering, ordering, or count authority.", enforcementPoint: compiled named read-path permission and route-specific query plan, refusal: "Require an independently granted path and enforce only that path's target fields and query capabilities; conceal unknown and ungranted paths identically before record I/O.", negativeId: BREG-NEG-21, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: relationship_route_uses_path_grant_not_direct_target_rights}} - {id: BREG-SEC-22, state: enforced, targetWave: W5, threat: A new profile or relationship grant omits mandatory entity access restrictions., enforcementPoint: compiler access requirements after module composition and before artifact generation, refusal: Reject profiles missing mandatory scopes or row bindings or widening allowed purposes; reject relationship grants whose target or join row requirements cannot be enforced., negativeId: BREG-NEG-22, negativeTest: {path: crates/registry-breg/tests/access_configuration.rs, name: requirements_are_mandatory_not_grants_and_cannot_be_weakened_by_profiles}} - - {id: BREG-SEC-23, state: enforced, targetWave: W5, threat: A caller uses map membership to query a Point without explicit spatial authority., enforcementPoint: compiled direct-list bbox grant and HTTP query admission, refusal: Reject malformed or undeclared bbox queries before record-service entry and retain the exact selected profile., negativeId: BREG-NEG-23, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: bbox_reaches_record_service_only_with_declared_spatial_grant}} + - {id: BREG-SEC-23, state: enforced, targetWave: W5, threat: A caller uses map membership to query a Point without explicit spatial authority., enforcementPoint: compiled direct-list bbox permission and HTTP query admission, refusal: Reject malformed or undeclared bbox queries before record-service entry and retain the exact selected profile., negativeId: BREG-NEG-23, negativeTest: {path: crates/registry-breg/tests/http_read_only.rs, name: bbox_reaches_record_service_only_with_declared_spatial_grant}} - {id: BREG-SEC-24, state: enforced, targetWave: W5, threat: Spatial predicates resolve through caller-controlled objects or a bbox role with excess authority., enforcementPoint: qualified PostGIS prerequisite verification and bbox role validation, refusal: Reject missing or misplaced extension prerequisites and bbox role privilege or membership drift before spatial execution., negativeId: BREG-NEG-24, negativeTest: {path: crates/registry-breg/tests/postgres_spatial_storage.rs, name: real_postgres_postgis_prerequisites_are_role_bound_and_shadow_resistant}} - {id: BREG-SEC-25, state: enforced, targetWave: W5, threat: A Rhai planner widens its package-declared entity target binding operation or field ceiling and turns script output into record authority., enforcementPoint: compiler-owned effect ceiling and the shared canonical proposal verifier before target locks or writes, refusal: Reject every undeclared write dimension before target locking and grant no authority from planner output., negativeId: BREG-NEG-25, negativeTest: {path: crates/registry-breg/src/request_prepare/tests.rs, name: rhai_planner_refuses_authority_ceiling_escape_before_target_locks}} - {id: BREG-SEC-26, state: enforced, targetWave: W5, threat: A Rhai planner bypasses configured review or fabricates a human decision through its result or application disposition., enforcementPoint: closed planner result ABI frozen separately from the static review policy and the canonical lifecycle transition engine, refusal: Reject planner-owned review fields and keep application unavailable until every configured stage is satisfied without synthesizing approval for a no-review policy., negativeId: BREG-NEG-26, negativeTest: {path: crates/registry-breg/src/request_workflow.rs, name: rhai_planner_review_policy_cannot_be_forged_or_bypassed}} @@ -32,7 +32,7 @@ invariants: - {id: BREG-SEC-29, state: enforced, targetWave: W5, threat: A buggy or hostile Rhai planner exhausts CPU stack memory proposal capacity or its request deadline and leaves the service unhealthy., enforcementPoint: package and runtime operation depth string collection input output proposal and deadline limits with fresh per-invocation state, refusal: Terminate over-budget execution with no frozen proposal lock or mutation allow no late commit and keep subsequent planner requests healthy., negativeId: BREG-NEG-29, negativeTest: {path: crates/registry-breg/tests/compiler_rhai_planner.rs, name: rhai_planner_resource_limits_and_deadline_leave_runtime_healthy}} - {id: BREG-SEC-30, state: enforced, targetWave: W5, threat: Rhai exceptions script fragments runtime values or dynamic queue reasons disclose request or operational data., enforcementPoint: value-free planner error mapping closed package-declared queue-reason catalog and minimized response log metric and audit vocabulary, refusal: Return one stable value-free failure for planner errors and reject undeclared reason strings without rendering script source dynamic values internal paths or canaries., negativeId: BREG-NEG-30, negativeTest: {path: crates/registry-breg/tests/compiler_rhai_planner.rs, name: rhai_planner_errors_and_queue_reasons_are_value_free}} - {id: BREG-SEC-31, state: enforced, targetWave: W5, threat: Automatic planner application unions actor profiles creates service authority falls back silently to a queue or commits a request transition separately from its effects., enforcementPoint: selected-profile application authorization and the canonical single-transaction lifecycle and mutation coordinator under the authenticated readiness actor, refusal: Refuse automatic application unless the same selected profile has ordinary application and target authority and roll back the transition effects receipt revision event outbox and audit together on denial stale state or failure., negativeId: BREG-NEG-31, negativeTest: {path: crates/registry-breg/tests/postgres_rhai_planner.rs, name: rhai_planner_automatic_transition_requires_same_profile_and_is_atomic}} - - {id: BREG-SEC-32, state: enforced, targetWave: W5, threat: "An omitted row declaration silently grants all records.", enforcementPoint: "authoring deserialization and generated schema", refusal: "Reject every row-bearing grant without an explicit rowBoundaries declaration; an empty array grants all rows only within mandatory requirements.", negativeId: BREG-NEG-32, negativeTest: {path: crates/registry-breg/tests/explicit_row_authority.rs, name: every_row_bearing_grant_requires_an_explicit_declaration}} + - {id: BREG-SEC-32, state: enforced, targetWave: W5, threat: "An omitted row declaration silently grants all records.", enforcementPoint: "authoring deserialization and generated schema", refusal: "Reject every row-bearing permission without an explicit rowBoundaries declaration; an empty array grants all rows only within mandatory requirements.", negativeId: BREG-NEG-32, negativeTest: {path: crates/registry-breg/tests/explicit_row_authority.rs, name: every_row_bearing_grant_requires_an_explicit_declaration}} - {id: BREG-SEC-33, state: enforced, targetWave: W5, threat: "A request with an ambiguous profile selects broader authority implicitly.", enforcementPoint: "compiled optional route default and HTTP profile selection", refusal: "Require an explicit profile when multiple profiles serve a route without a default; refuse before record I/O without searching for a qualifying profile.", negativeId: BREG-NEG-33, negativeTest: {path: crates/registry-breg/tests/http_auth.rs, name: ambiguous_route_without_default_requires_explicit_selection_without_guessing_authority}} - {id: BREG-SEC-34, state: enforced, targetWave: W5, threat: "One actor satisfies successive review stages that require independent reviewers.", enforcementPoint: "frozen review policy and canonical workflow transition", refusal: "When excludePreviousReviewers is configured reject a prior-stage actor for the same proposal version including after workflow restoration.", negativeId: BREG-NEG-34, negativeTest: {path: crates/registry-breg/src/request_workflow.rs, name: prior_stage_actor_is_excluded_only_when_the_stage_requires_independence}} - {id: BREG-SEC-35, state: enforced, targetWave: W5, threat: A related-record acceptance decision races with a referenced record update or bypasses its declared processing authority., enforcementPoint: compiled action requirements and existing ordered target locks before effects, refusal: Refuse an unmet requirement without committing any action effects and preserve its original acceptance result on exact replay., negativeId: BREG-NEG-35, negativeTest: {path: crates/registry-breg/tests/postgres_immediate_action_requirements.rs, name: action_requirements_hold_reference_lock_against_concurrent_inactivation}} @@ -64,3 +64,4 @@ invariants: - {id: BREG-SEC-61, state: enforced, targetWave: W5, threat: Request review stage and review timing reach a request reader whose grant does not name review_state., enforcementPoint: authenticated selected-profile request metadata projection, refusal: Withhold review and reviewTiming from anonymous readers and from profiles whose readableRequestFields omit review_state., negativeId: BREG-NEG-61, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: decision_reason_disclosure_requires_selected_profile_permission_and_authentication}} - {id: BREG-SEC-62, state: enforced, targetWave: W5, threat: Request actor references reach a request reader whose grant does not name actor_reference., enforcementPoint: authenticated selected-profile request metadata projection, refusal: Withhold actor references from anonymous readers and from profiles whose readableRequestFields omit actor_reference., negativeId: BREG-NEG-62, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: decision_reason_disclosure_requires_selected_profile_permission_and_authentication}} - {id: BREG-SEC-63, state: enforced, targetWave: W5, threat: A disclosed request actor reference reveals the principal or links one principal across source databases., enforcementPoint: keyed audit reference hash bound to the request actor class and the source database identifier, refusal: Derive every request actor reference from the audit key and the source database so it never carries the principal and differs per database and per key., negativeId: BREG-NEG-63, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: request_actor_reference_is_a_keyed_hash_scoped_to_the_source_database}} + - {id: BREG-SEC-64, state: enforced, targetWave: W5, threat: Revoked task authority commits another draft or proposal mutation or a later reviewer replaces the original authority or a retry reapplies a completed proposal., enforcementPoint: verified identity-bound HTTP claims propagated into SQL context and fresh per-attempt status with immutable proposal authority and grant-bound write idempotency, refusal: Refuse revoked or unavailable authority before new mutations and each SQL retry and recheck original authority for approval and application while recovering completed receipts only under current disclosure., negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_task_grants.rs, name: task_http_to_postgres_preserves_original_authority_and_completed_receipts}} diff --git a/products/breg/contracts/security-test-traceability.yaml b/products/breg/contracts/security-test-traceability.yaml index 426869fd01..7e437593e0 100644 --- a/products/breg/contracts/security-test-traceability.yaml +++ b/products/breg/contracts/security-test-traceability.yaml @@ -64,3 +64,4 @@ traceability: - {id: BREG-SEC-61, state: enforced, negativeId: BREG-NEG-61, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: decision_reason_disclosure_requires_selected_profile_permission_and_authentication}} - {id: BREG-SEC-62, state: enforced, negativeId: BREG-NEG-62, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: decision_reason_disclosure_requires_selected_profile_permission_and_authentication}} - {id: BREG-SEC-63, state: enforced, negativeId: BREG-NEG-63, negativeTest: {path: crates/registry-breg/src/postgres/request_read.rs, name: request_actor_reference_is_a_keyed_hash_scoped_to_the_source_database}} + - {id: BREG-SEC-64, state: enforced, negativeId: BREG-NEG-64, negativeTest: {path: crates/registry-breg/tests/postgres_task_grants.rs, name: task_http_to_postgres_preserves_original_authority_and_completed_receipts}} diff --git a/products/breg/evidence/registry/registry.yaml b/products/breg/evidence/registry/registry.yaml index 21d038b3dd..c8a1482731 100644 --- a/products/breg/evidence/registry/registry.yaml +++ b/products/breg/evidence/registry/registry.yaml @@ -85,7 +85,7 @@ accessProfiles: - registry:generic:operate requiredPurposes: - registry-operations - grants: + permissions: - entity: record operations: - create @@ -108,7 +108,7 @@ accessProfiles: - registry:evidence:lookup requiredPurposes: - evidence-source-read - grants: + permissions: - entity: record operations: - lookup diff --git a/products/breg/evidence/tests/verify-composition.py b/products/breg/evidence/tests/verify-composition.py index f970b8f784..539e5df44a 100644 --- a/products/breg/evidence/tests/verify-composition.py +++ b/products/breg/evidence/tests/verify-composition.py @@ -339,9 +339,13 @@ def token(session: dict[str, object], client_name: str) -> str: entity["fields"].append({"id": "operator-note", "type": "string", "maxLength": 64, "classification": "internal"}) operator_profile = next(profile for profile in authored["accessProfiles"] if profile["id"] == "operator") - grant = next(grant for grant in operator_profile["grants"] if grant["entity"] == "record") - for permission in ["readableFields", "writableFields"]: - grant[permission].append("operator-note") + permission = next( + permission + for permission in operator_profile["permissions"] + if permission["entity"] == "record" + ) + for field_list in ["readableFields", "writableFields"]: + permission[field_list].append("operator-note") (registry / "registry.yaml").write_text(yaml.safe_dump(authored, sort_keys=False)) else: command("bregctl", "init", registry) diff --git a/products/breg/examples/access-review/README.md b/products/breg/examples/access-review/README.md index fe64131399..6bf9f4e6ca 100644 --- a/products/breg/examples/access-review/README.md +++ b/products/breg/examples/access-review/README.md @@ -58,14 +58,14 @@ Use an explicit empty list to distinguish intentional registry-wide access from The [task-profile project](task-profiles/README.md) adds a clerk, supervisor, auditor, action-only registrar, and reviewed correction to a small registry. -It demonstrates how to use existing grants for different tasks without merging profiles. +It demonstrates how to use existing permissions for different tasks without merging profiles. ## Requirements and limits `accessRequirements` is optional and requires authenticated access when present. It grants nothing. Every direct profile, including module contributions, must explicitly include its mandatory scopes and exact row bindings. Action targets -and workflow review, application, and request-presence grants also preserve the +and workflow review, application, and request-presence permissions also preserve the requirements of the entities they touch. When `allowedPurposes` is nonempty, profiles must restrict purpose to a nonempty subset of it. An empty or omitted list imposes no purpose requirement. Profiles may be stricter. @@ -75,7 +75,7 @@ that has none, but cannot replace existing requirements. Relationship routes are authorized by the root profile. Target and join entity scope/purpose requirements apply to that profile too. Target or join row requirements cannot be enforced by the current root-only relationship plan, so -such grants fail compilation. Use a direct route on the protected entity. +such permissions fail compilation. Use a direct route on the protected entity. Root row requirements continue to work for relationship routes. Requirements govern request access, not independently configured event @@ -85,7 +85,7 @@ Requirements also do not stop an authorized operator from replacing the complete configuration. Review, package signatures, and deployment authority still matter. `diff` reports field-by-field access changes, including scopes, purposes, row -bindings, fields, related-record grants, and export/history permissions. Mixed +bindings, fields, related-record permissions, and export/history permissions. Mixed changes are marked for review rather than assigned a guessed overall direction. Changing mandatory requirements is included in package migration/change review. diff --git a/products/breg/examples/access-review/registry.yaml b/products/breg/examples/access-review/registry.yaml index aaf6bcfb70..41d11b42ae 100644 --- a/products/breg/examples/access-review/registry.yaml +++ b/products/breg/examples/access-review/registry.yaml @@ -46,7 +46,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:read] requiredPurposes: [record-administration] - grants: + permissions: - entity: record operations: [get, list] readableFields: [code, label, district] diff --git a/products/breg/examples/access-review/task-profiles/registry.yaml b/products/breg/examples/access-review/task-profiles/registry.yaml index 8d8b259abe..418a70c1be 100644 --- a/products/breg/examples/access-review/task-profiles/registry.yaml +++ b/products/breg/examples/access-review/task-profiles/registry.yaml @@ -91,7 +91,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:read] requiredPurposes: [record-administration] - grants: + permissions: - entity: record operations: [get, list] readableFields: [code, label, district, owner] @@ -102,7 +102,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:edit-own] requiredPurposes: [record-administration] - grants: + permissions: - entity: record operations: [get, patch] readableFields: [code, label, district, owner] @@ -114,7 +114,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:supervise] requiredPurposes: [record-administration] - grants: + permissions: - entity: record operations: [get, list, patch] readableFields: [code, label, district, owner] @@ -129,7 +129,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:audit] requiredPurposes: [record-audit] - grants: + permissions: - entity: record operations: [get, revisions] readableFields: [code, label, district] @@ -140,7 +140,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [record:register] requiredPurposes: [record-administration] - grants: + permissions: - action: register-record operations: [invoke] targets: @@ -154,7 +154,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [correction:submit] requiredPurposes: [record-administration] - grants: + permissions: - entity: label-correction operations: [create, get, patch, submit_request, revise_request, cancel_request] readableFields: [record, label, district] @@ -166,7 +166,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [correction:review] requiredPurposes: [record-administration] - grants: + permissions: - entity: label-correction operations: [get, approve_request, reject_request, request_revision] readableFields: [record, label, district] @@ -183,7 +183,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [correction:final-approve] requiredPurposes: [record-administration] - grants: + permissions: - entity: label-correction operations: [get, approve_request, reject_request, request_revision] readableFields: [record, label, district] @@ -200,7 +200,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [correction:apply] requiredPurposes: [record-administration] - grants: + permissions: - entity: label-correction operations: [get, apply_request] readableFields: [record, label, district] diff --git a/products/breg/fixtures/asset-registration-actions/registry.yaml b/products/breg/fixtures/asset-registration-actions/registry.yaml index 7bb67d2d09..2e59d35098 100644 --- a/products/breg/fixtures/asset-registration-actions/registry.yaml +++ b/products/breg/fixtures/asset-registration-actions/registry.yaml @@ -46,7 +46,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:asset:register] requiredPurposes: [asset-registration] - grants: + permissions: - action: register-asset-with-inspection operations: [invoke] targets: @@ -61,7 +61,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:asset:audit] requiredPurposes: [asset-audit] - grants: + permissions: - entity: asset operations: [get, list] readableFields: [asset-code, label, asset-type, jurisdiction] diff --git a/products/breg/fixtures/facility-registry-actions/registry.yaml b/products/breg/fixtures/facility-registry-actions/registry.yaml index 72755a052f..a5efc3b6cb 100644 --- a/products/breg/fixtures/facility-registry-actions/registry.yaml +++ b/products/breg/fixtures/facility-registry-actions/registry.yaml @@ -45,7 +45,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:facility:register] requiredPurposes: [facility-administration] - grants: + permissions: - action: register-facility operations: [invoke] targets: @@ -61,7 +61,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:facility:transfer] requiredPurposes: [facility-administration] - grants: + permissions: - action: transfer-facility operations: [invoke] targets: @@ -73,7 +73,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:facility:read] requiredPurposes: [facility-administration] - grants: + permissions: - entity: facility operations: [get, list] readableFields: [facility-code, label, owner] @@ -84,7 +84,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:operator:manage] requiredPurposes: [facility-administration] - grants: + permissions: - entity: operator rowBoundaries: [] operations: [create, get, patch] diff --git a/products/breg/fixtures/household-contact-actions/registry.yaml b/products/breg/fixtures/household-contact-actions/registry.yaml index b4bc6be0f1..fe66a6f2b8 100644 --- a/products/breg/fixtures/household-contact-actions/registry.yaml +++ b/products/breg/fixtures/household-contact-actions/registry.yaml @@ -46,7 +46,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:operate] requiredPurposes: [household-administration] - grants: + permissions: - entity: household operations: [create, get] readableFields: [household-code, household-name, district, contact-person] @@ -74,7 +74,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:household:maintain] requiredPurposes: [household-maintenance] - grants: + permissions: - entity: household operations: [get, patch] readableFields: [household-code, household-name, district, contact-person] @@ -85,7 +85,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:contact:register] requiredPurposes: [contact-registration] - grants: + permissions: - action: register-household-contact operations: [invoke] targets: diff --git a/products/breg/fixtures/organization-membership-access/registry.yaml b/products/breg/fixtures/organization-membership-access/registry.yaml index 91ca957897..f801802b32 100644 --- a/products/breg/fixtures/organization-membership-access/registry.yaml +++ b/products/breg/fixtures/organization-membership-access/registry.yaml @@ -77,7 +77,7 @@ accessProfiles: requiredScopes: - records:read - membership:use - grants: + permissions: - entity: facility rowBoundaries: [] operations: @@ -108,7 +108,7 @@ accessProfiles: requiredScopes: - records:manage - membership:use - grants: + permissions: - entity: organization rowBoundaries: [] operations: diff --git a/products/breg/generated/asset-registration-actions/compiled/actions.json b/products/breg/generated/asset-registration-actions/compiled/actions.json index 6431cc7c85..7878427532 100644 --- a/products/breg/generated/asset-registration-actions/compiled/actions.json +++ b/products/breg/generated/asset-registration-actions/compiled/actions.json @@ -1 +1 @@ -{"access":[{"actionId":"register-asset-with-inspection","defaultProfileId":"asset-action-registrar","operation":"invoke","profileIds":["asset-action-registrar"],"routeId":"actions.register-asset-with-inspection.invoke"}],"actions":[{"contractFingerprint":"sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2","effects":[{"dependsOn":[],"id":"asset","mutations":[{"field":"asset-code","kind":"set","value":{"input":"asset-code","kind":"fromInput"}},{"field":"asset-type","kind":"set","value":{"input":"asset-type","kind":"fromInput"}},{"field":"jurisdiction","kind":"set","value":{"input":"jurisdiction","kind":"fromInput"}},{"field":"label","kind":"set","value":{"input":"label","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"asset"}},{"dependsOn":["asset"],"id":"initial-inspection","mutations":[{"field":"asset","kind":"set","value":{"effect":"asset","kind":"fromEffect","target_entity_id":"asset"}},{"field":"jurisdiction","kind":"set","value":{"input":"jurisdiction","kind":"fromInput"}},{"field":"observed-at","kind":"set","value":{"input":"observed-at","kind":"fromInput"}},{"field":"result","kind":"set","value":{"input":"inspection-result","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"asset-inspection"}}],"grants":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"asset-action-registrar","requiredPurposes":["asset-registration"],"requiredScopes":["registry:asset:register"],"results":["asset"],"targets":[{"entityId":"asset","rowBoundaries":[{"claim":"jurisdiction","field":"jurisdiction","operator":"equals"}]},{"entityId":"asset-inspection","rowBoundaries":[{"claim":"jurisdiction","field":"jurisdiction","operator":"equals"}]}]}],"id":"register-asset-with-inspection","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"resultEffects":["asset","initial-inspection"],"route":"/v1/actions/register-asset-with-inspection","sourceModule":"asset-registration-actions-core","targetUses":[{"conditionRequired":false,"entityId":"asset","fields":["asset-code","asset-type","jurisdiction","label"],"operation":"create","source":{"effect":"asset","kind":"effect"}},{"conditionRequired":false,"entityId":"asset-inspection","fields":["asset","jurisdiction","observed-at","result"],"operation":"create","source":{"effect":"initial-inspection","kind":"effect"}}]}],"routes":[{"accessProfiles":["asset-action-registrar"],"actionId":"register-asset-with-inspection","defaultAccessProfile":"asset-action-registrar","id":"actions.register-asset-with-inspection.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-asset-with-inspection"}]} \ No newline at end of file +{"access":[{"actionId":"register-asset-with-inspection","defaultProfileId":"asset-action-registrar","operation":"invoke","profileIds":["asset-action-registrar"],"routeId":"actions.register-asset-with-inspection.invoke"}],"actions":[{"contractFingerprint":"sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8","effects":[{"dependsOn":[],"id":"asset","mutations":[{"field":"asset-code","kind":"set","value":{"input":"asset-code","kind":"fromInput"}},{"field":"asset-type","kind":"set","value":{"input":"asset-type","kind":"fromInput"}},{"field":"jurisdiction","kind":"set","value":{"input":"jurisdiction","kind":"fromInput"}},{"field":"label","kind":"set","value":{"input":"label","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"asset"}},{"dependsOn":["asset"],"id":"initial-inspection","mutations":[{"field":"asset","kind":"set","value":{"effect":"asset","kind":"fromEffect","target_entity_id":"asset"}},{"field":"jurisdiction","kind":"set","value":{"input":"jurisdiction","kind":"fromInput"}},{"field":"observed-at","kind":"set","value":{"input":"observed-at","kind":"fromInput"}},{"field":"result","kind":"set","value":{"input":"inspection-result","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"asset-inspection"}}],"id":"register-asset-with-inspection","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"permissions":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"asset-action-registrar","requiredPurposes":["asset-registration"],"requiredScopes":["registry:asset:register"],"results":["asset"],"targets":[{"entityId":"asset","rowBoundaries":[{"claim":"jurisdiction","field":"jurisdiction","operator":"equals"}]},{"entityId":"asset-inspection","rowBoundaries":[{"claim":"jurisdiction","field":"jurisdiction","operator":"equals"}]}]}],"resultEffects":["asset","initial-inspection"],"route":"/v1/actions/register-asset-with-inspection","sourceModule":"asset-registration-actions-core","targetUses":[{"conditionRequired":false,"entityId":"asset","fields":["asset-code","asset-type","jurisdiction","label"],"operation":"create","source":{"effect":"asset","kind":"effect"}},{"conditionRequired":false,"entityId":"asset-inspection","fields":["asset","jurisdiction","observed-at","result"],"operation":"create","source":{"effect":"initial-inspection","kind":"effect"}}]}],"routes":[{"accessProfiles":["asset-action-registrar"],"actionId":"register-asset-with-inspection","defaultAccessProfile":"asset-action-registrar","id":"actions.register-asset-with-inspection.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-asset-with-inspection"}]} \ No newline at end of file diff --git a/products/breg/generated/asset-registration-actions/generated/metadata/registry.json b/products/breg/generated/asset-registration-actions/generated/metadata/registry.json index 0423b88146..117797475f 100644 --- a/products/breg/generated/asset-registration-actions/generated/metadata/registry.json +++ b/products/breg/generated/asset-registration-actions/generated/metadata/registry.json @@ -1 +1 @@ -{"actions":[{"access":{"accessProfiles":["asset-action-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2","id":"register-asset-with-inspection","inputMode":"fixed","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","nullable":false,"required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","nullable":false,"required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","nullable":false,"required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","nullable":false,"required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","nullable":false,"required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"asset","entity":"asset","operation":"create"},{"effect":"initial-inspection","entity":"asset-inspection","operation":"create"}],"route":"/v1/actions/register-asset-with-inspection","routes":{"invoke":{"inputSchema":"action-register-asset-with-inspection-invoke-input","method":"POST","operationId":"actions.register-asset-with-inspection.invoke","path":"/v1/actions/register-asset-with-inspection","requiresIdempotencyKey":true,"responseSchema":"action-register-asset-with-inspection-invoke-response"},"targetConditions":null}}],"entities":[{"entries":[{"accessProfile":"asset-auditor","operation":"get","readableFields":["asset-code","asset-type","jurisdiction","label"],"responseEntityId":"asset","routeId":"records.asset.get"},{"accessProfile":"asset-auditor","operation":"list","readableFields":["asset-code","asset-type","jurisdiction","label"],"responseEntityId":"asset","routeId":"records.asset.list"}],"id":"asset","route":"assets","schemaPath":"/v1/schemas/asset"},{"entries":[{"accessProfile":"asset-auditor","operation":"get","readableFields":["asset","jurisdiction","observed-at","result"],"responseEntityId":"asset-inspection","routeId":"records.asset-inspection.get"},{"accessProfile":"asset-auditor","operation":"list","readableFields":["asset","jurisdiction","observed-at","result"],"responseEntityId":"asset-inspection","routeId":"records.asset-inspection.list"}],"id":"asset-inspection","route":"inspections","schemaPath":"/v1/schemas/asset-inspection"}],"registryId":"asset-registration-actions","version":"0.1.0"} \ No newline at end of file +{"actions":[{"access":{"accessProfiles":["asset-action-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8","id":"register-asset-with-inspection","inputMode":"fixed","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","nullable":false,"required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","nullable":false,"required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","nullable":false,"required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","nullable":false,"required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","nullable":false,"required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"asset","entity":"asset","operation":"create"},{"effect":"initial-inspection","entity":"asset-inspection","operation":"create"}],"route":"/v1/actions/register-asset-with-inspection","routes":{"invoke":{"inputSchema":"action-register-asset-with-inspection-invoke-input","method":"POST","operationId":"actions.register-asset-with-inspection.invoke","path":"/v1/actions/register-asset-with-inspection","requiresIdempotencyKey":true,"responseSchema":"action-register-asset-with-inspection-invoke-response"},"targetConditions":null}}],"entities":[{"entries":[{"accessProfile":"asset-auditor","operation":"get","readableFields":["asset-code","asset-type","jurisdiction","label"],"responseEntityId":"asset","routeId":"records.asset.get"},{"accessProfile":"asset-auditor","operation":"list","readableFields":["asset-code","asset-type","jurisdiction","label"],"responseEntityId":"asset","routeId":"records.asset.list"}],"id":"asset","route":"assets","schemaPath":"/v1/schemas/asset"},{"entries":[{"accessProfile":"asset-auditor","operation":"get","readableFields":["asset","jurisdiction","observed-at","result"],"responseEntityId":"asset-inspection","routeId":"records.asset-inspection.get"},{"accessProfile":"asset-auditor","operation":"list","readableFields":["asset","jurisdiction","observed-at","result"],"responseEntityId":"asset-inspection","routeId":"records.asset-inspection.list"}],"id":"asset-inspection","route":"inspections","schemaPath":"/v1/schemas/asset-inspection"}],"registryId":"asset-registration-actions","version":"0.1.0"} \ No newline at end of file diff --git a/products/breg/generated/asset-registration-actions/generated/openapi.json b/products/breg/generated/asset-registration-actions/generated/openapi.json index 37ee172bdb..e01171e44e 100644 --- a/products/breg/generated/asset-registration-actions/generated/openapi.json +++ b/products/breg/generated/asset-registration-actions/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-asset-with-inspection-invoke-input":{"$id":"urn:breg:action:register-asset-with-inspection:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"assetType":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-type"},"initialResult":{"enum":["passed","failed","needs-follow-up"],"type":"string","x-registry-vocabulary":"inspection-result"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"},"observedAt":{"format":"date-time","type":"string"}},"required":["assetCode","label","assetType","jurisdiction","observedAt","initialResult"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-asset-with-inspection","x-registry-requiredConditionKeys":[]},"action-register-asset-with-inspection-invoke-response":{"$id":"urn:breg:action:register-asset-with-inspection:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-asset-with-inspection"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"asset":{"additionalProperties":false,"properties":{"entity":{"const":"asset"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":["asset"],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"asset":{"$id":"urn:breg:entity:asset","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"assetType":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-type"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetType","jurisdiction"],"type":"object","x-registry-mutationMode":"mutable"},"asset-inspection":{"$id":"urn:breg:entity:asset-inspection","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed","needs-follow-up"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result","jurisdiction"],"type":"object","x-registry-mutationMode":"create_only"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"asset-registration-actions","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-asset-with-inspection":{"post":{"operationId":"actions.register-asset-with-inspection.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-asset-with-inspection-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-asset-with-inspection-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-action-registrar"],"x-registry-action":{"access":{"accessProfiles":["asset-action-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2","id":"register-asset-with-inspection","inputMode":"fixed","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","nullable":false,"required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","nullable":false,"required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","nullable":false,"required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","nullable":false,"required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","nullable":false,"required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"asset","entity":"asset","operation":"create"},{"effect":"initial-inspection","entity":"asset-inspection","operation":"create"}],"route":"/v1/actions/register-asset-with-inspection","routes":{"invoke":{"inputSchema":"action-register-asset-with-inspection-invoke-input","method":"POST","operationId":"actions.register-asset-with-inspection.invoke","path":"/v1/actions/register-asset-with-inspection","requiresIdempotencyKey":true,"responseSchema":"action-register-asset-with-inspection-invoke-response"},"targetConditions":null}},"x-registry-defaultAccessProfile":"asset-action-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/records/assets":{"get":{"operationId":"records.asset.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-auditor":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"assetCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"jurisdiction"}],"kind":"list","maxPageSize":100,"profile":"asset-auditor","selectableProperties":["assetCode","assetType","jurisdiction","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset","x-registry-operation":"get","x-registry-responseEntity":"asset","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/inspections":{"get":{"operationId":"records.asset-inspection.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset-inspection","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-auditor":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"jurisdiction"}],"kind":"list","maxPageSize":100,"profile":"asset-auditor","selectableProperties":["asset","jurisdiction","observedAt","result"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-inspection","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.asset-inspection.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset-inspection","x-registry-operation":"get","x-registry-responseEntity":"asset-inspection","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-asset-with-inspection-invoke-input":{"$id":"urn:breg:action:register-asset-with-inspection:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"assetType":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-type"},"initialResult":{"enum":["passed","failed","needs-follow-up"],"type":"string","x-registry-vocabulary":"inspection-result"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"},"observedAt":{"format":"date-time","type":"string"}},"required":["assetCode","label","assetType","jurisdiction","observedAt","initialResult"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-asset-with-inspection","x-registry-requiredConditionKeys":[]},"action-register-asset-with-inspection-invoke-response":{"$id":"urn:breg:action:register-asset-with-inspection:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-asset-with-inspection"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"asset":{"additionalProperties":false,"properties":{"entity":{"const":"asset"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":["asset"],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"asset":{"$id":"urn:breg:entity:asset","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"assetType":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-type"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetType","jurisdiction"],"type":"object","x-registry-mutationMode":"mutable"},"asset-inspection":{"$id":"urn:breg:entity:asset-inspection","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"jurisdiction":{"maxLength":80,"minLength":0,"type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed","needs-follow-up"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result","jurisdiction"],"type":"object","x-registry-mutationMode":"create_only"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"asset-registration-actions","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-asset-with-inspection":{"post":{"operationId":"actions.register-asset-with-inspection.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-asset-with-inspection-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-asset-with-inspection-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-action-registrar"],"x-registry-action":{"access":{"accessProfiles":["asset-action-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8","id":"register-asset-with-inspection","inputMode":"fixed","inputs":[{"apiName":"assetCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"asset-code","nullable":false,"required":true},{"apiName":"label","classification":"internal","fieldType":{"maxLength":200,"minLength":0,"type":"string"},"id":"label","nullable":false,"required":true},{"apiName":"assetType","classification":"internal","fieldType":{"type":"vocabulary-code","values":["equipment","vehicle","furniture"],"vocabulary":"asset-type"},"id":"asset-type","nullable":false,"required":true},{"apiName":"jurisdiction","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"jurisdiction","nullable":false,"required":true},{"apiName":"observedAt","classification":"internal","fieldType":{"type":"timestamp"},"id":"observed-at","nullable":false,"required":true},{"apiName":"initialResult","classification":"internal","fieldType":{"type":"vocabulary-code","values":["passed","failed","needs-follow-up"],"vocabulary":"inspection-result"},"id":"inspection-result","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"asset","entity":"asset","operation":"create"},{"effect":"initial-inspection","entity":"asset-inspection","operation":"create"}],"route":"/v1/actions/register-asset-with-inspection","routes":{"invoke":{"inputSchema":"action-register-asset-with-inspection-invoke-input","method":"POST","operationId":"actions.register-asset-with-inspection.invoke","path":"/v1/actions/register-asset-with-inspection","requiresIdempotencyKey":true,"responseSchema":"action-register-asset-with-inspection-invoke-response"},"targetConditions":null}},"x-registry-defaultAccessProfile":"asset-action-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/records/assets":{"get":{"operationId":"records.asset.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-auditor":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"assetCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"jurisdiction"}],"kind":"list","maxPageSize":100,"profile":"asset-auditor","selectableProperties":["assetCode","assetType","jurisdiction","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset","x-registry-operation":"get","x-registry-responseEntity":"asset","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/inspections":{"get":{"operationId":"records.asset-inspection.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset-inspection","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-auditor":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"jurisdiction"}],"kind":"list","maxPageSize":100,"profile":"asset-auditor","selectableProperties":["asset","jurisdiction","observedAt","result"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-inspection","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.asset-inspection.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-inspection"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-registration-actions"},"entityTypeIdentifier":{"const":"asset-inspection"},"registryIdentifier":{"const":"asset-registration-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-auditor"],"x-registry-entity":"asset-inspection","x-registry-operation":"get","x-registry-responseEntity":"asset-inspection","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/asset-registration-actions/generated/postgres/schema.sql b/products/breg/generated/asset-registration-actions/generated/postgres/schema.sql index d29ec4338e..bfb2d38c1e 100644 --- a/products/breg/generated/asset-registration-actions/generated/postgres/schema.sql +++ b/products/breg/generated/asset-registration-actions/generated/postgres/schema.sql @@ -17,13 +17,13 @@ ALTER TABLE registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" ADD CONSTRA ALTER TABLE registry_data."breg_e_asset_91f8b886fcd3f284" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_asset_91f8b886fcd3f284" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_caf6c4dcc7e9c2d3d14905b1" ON registry_data."breg_e_asset_91f8b886fcd3f284" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-auditor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-audit') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'jurisdiction' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_asset_jurisdiction_98b3c29803efcd00" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_325cef35ba295f7e03d9b7cb" ON registry_data."breg_e_asset_91f8b886fcd3f284" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'asset' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('asset') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('asset', 'asset-code'), ('asset', 'asset-type'), ('asset', 'jurisdiction'), ('asset', 'label')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_jurisdiction_98b3c29803efcd00" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_da86c8ef1a9e344ad5672ba1" ON registry_data."breg_e_asset_91f8b886fcd3f284" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'asset' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('asset') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('asset', 'asset-code'), ('asset', 'asset-type'), ('asset', 'jurisdiction'), ('asset', 'label')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_jurisdiction_98b3c29803efcd00" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_325cef35ba295f7e03d9b7cb" ON registry_data."breg_e_asset_91f8b886fcd3f284" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'asset' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('asset') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('asset', 'asset-code'), ('asset', 'asset-type'), ('asset', 'jurisdiction'), ('asset', 'label')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_jurisdiction_98b3c29803efcd00" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_da86c8ef1a9e344ad5672ba1" ON registry_data."breg_e_asset_91f8b886fcd3f284" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'asset' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('asset') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('asset', 'asset-code'), ('asset', 'asset-type'), ('asset', 'jurisdiction'), ('asset', 'label')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_jurisdiction_98b3c29803efcd00" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); ALTER TABLE registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_08421ff7df14b610e2087687" ON registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'asset-auditor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-audit') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'jurisdiction' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_asset_inspection_jurisdiction_3b819c275b3fb300" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_b304a61a503fc5fcd304604f" ON registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'initial-inspection' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('initial-inspection') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('initial-inspection', 'asset'), ('initial-inspection', 'jurisdiction'), ('initial-inspection', 'observed-at'), ('initial-inspection', 'result')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_inspection_jurisdiction_3b819c275b3fb300" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_61677a33969a98e38ecf6d4c" ON registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:3c6a7ef337decfdc441482628d91752bcae8421e3a119f2fa09c3d5475c31fa2' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'initial-inspection' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('initial-inspection') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('initial-inspection', 'asset'), ('initial-inspection', 'jurisdiction'), ('initial-inspection', 'observed-at'), ('initial-inspection', 'result')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_inspection_jurisdiction_3b819c275b3fb300" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_b304a61a503fc5fcd304604f" ON registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'initial-inspection' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('initial-inspection') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('initial-inspection', 'asset'), ('initial-inspection', 'jurisdiction'), ('initial-inspection', 'observed-at'), ('initial-inspection', 'result')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_inspection_jurisdiction_3b819c275b3fb300" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_61677a33969a98e38ecf6d4c" ON registry_data."breg_e_asset_inspection_fa33c8d3f46617cf" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-asset-with-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2f83ce6382ec3c669d2afcf4b68a9b52e79c75e5b820a4a8002f8e32b17716c8' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-action-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'initial-inspection' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('initial-inspection') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('initial-inspection', 'asset'), ('initial-inspection', 'jurisdiction'), ('initial-inspection', 'observed-at'), ('initial-inspection', 'result')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-inspection' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'jurisdiction' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_asset_inspection_jurisdiction_3b819c275b3fb300" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); CREATE VIEW registry_source."asset" WITH (security_invoker=true, security_barrier=true) AS SELECT record_id AS "id", "breg_f_asset_asset_code_17984f3b2b8df84d" AS "asset_code", "breg_f_asset_label_f594cf8ad301601d" AS "label", "breg_f_asset_asset_type_fd3d78670e1483b4" AS "asset_type", "breg_f_asset_jurisdiction_98b3c29803efcd00" AS "jurisdiction" diff --git a/products/breg/generated/asset-site-placement-change-requests/generated/openapi.json b/products/breg/generated/asset-site-placement-change-requests/generated/openapi.json index af53ad19d7..de6f3d1d2e 100644 --- a/products/breg/generated/asset-site-placement-change-requests/generated/openapi.json +++ b/products/breg/generated/asset-site-placement-change-requests/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"asset-item":{"$id":"urn:breg:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-batch-input":{"$id":"urn:breg:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-create-input":{"$id":"urn:breg:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:breg:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce","operations":["patch"],"requestEntity":"placement-correction-request","requestRoute":"placement-correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"asset-placement-create-input":{"$id":"urn:breg:entity:asset-placement:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:breg:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site-create-input":{"$id":"urn:breg:entity:asset-site:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:breg:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"},"inspection-event-create-input":{"$id":"urn:breg:entity:inspection-event:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"},"placement-correction-request":{"$id":"urn:breg:entity:placement-correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"placement-correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"correction-applier","rowBoundaries":[],"targetEntityId":"asset-placement"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce","effects":[{"dependsOn":[],"id":"effect-1","mutations":[{"apiName":"site","field":"site","kind":"set","value":{"field":"proposed-site","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"placement","kind":"existing"},"entity":"asset-placement"}}],"planner":{"kind":"declarative"},"presenceGrants":[{"profileId":"asset-operator","requestRowBoundaries":[],"targetEntityId":"asset-placement"},{"profileId":"site-planner","requestRowBoundaries":[],"targetEntityId":"asset-placement"}],"requestEntity":"placement-correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"correction-supervisor","readableFields":["site"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"asset-placement"},{"profileId":"correction-reviewer","readableFields":["site"],"rowBoundaries":[],"stage":"review","targetEntityId":"asset-placement"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["asset-placement"]},"x-registry-mutationMode":"mutable"},"placement-correction-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"placement-correction-request-create-input":{"$id":"urn:breg:entity:placement-correction-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-mutationMode":"mutable"},"placement-correction-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-revise_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"rebase":{"type":"boolean"}},"required":["rebase"],"type":"object"},"placement-correction-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"asset-site-placement-change-requests","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["assetClass","assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"assetCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-item.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.asset-item.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"changeContext":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"correction"}},"required":["kind"]},"then":{"required":["reasonCode"]}}],"properties":{"kind":{"enum":["change","correction"],"type":"string"},"reasonCode":{"maxLength":64,"minLength":1,"type":"string","x-registry-maxBytes":64},"reasonText":{"maxLength":4096,"minLength":1,"type":"string","x-registry-maxBytes":4096},"sourceReferences":{"items":{"maxLength":256,"minLength":1,"type":"string","x-registry-maxBytes":256},"maxItems":16,"type":"array"}},"type":"object","x-registry-maxCanonicalBytes":16384},"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-batch-input"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"operation":{"const":"patch"},"patch":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["snapshot","results"],"type":"object"}}},"description":"Atomic batch committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch","x-registry-responseEntity":"asset-item","x-registry-responseShape":"BRegAtomicBatchMutationResponseV1"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","observedAt","result"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.inspection-event.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create","x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get","x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests":{"get":{"operationId":"records.placement-correction-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer","correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"correction-reviewer":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"correction-reviewer","selectableProperties":["placement","proposedSite","reason"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["placement","proposedSite","reason"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.placement-correction-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/placement-correction-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"create","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests/{record_id}":{"get":{"operationId":"records.placement-correction-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-applier","correction-reviewer","correction-submitter","correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"get","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.placement-correction-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"patch","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/apply":{"post":{"operationId":"records.placement-correction-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-applier"],"x-registry-entity":"placement-correction-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.placement-correction-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/revise":{"post":{"operationId":"records.placement-correction-request.request.revise","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-revise_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"revise_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-revise_request-input","method":"post","operation":"revise_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/revise","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/approve":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/reject":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/request-revision":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"placement-correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.placement-correction-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.placement-correction-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.placement-correction-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"placement-correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/submit":{"post":{"operationId":"records.placement-correction-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-placement.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create","x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get","x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"as_of","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"as_of","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"current","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"current","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"siteCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-site.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.asset-site.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"asset-item":{"$id":"urn:breg:entity:asset-item","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-batch-input":{"$id":"urn:breg:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-item-create-input":{"$id":"urn:breg:entity:asset-item:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"assetClass":{"enum":["equipment","vehicle","furniture"],"type":"string","x-registry-vocabulary":"asset-classification"},"assetCode":{"maxLength":64,"minLength":0,"type":"string"},"label":{"maxLength":200,"minLength":0,"type":"string"}},"required":["assetCode","label","assetClass"],"type":"object","x-registry-mutationMode":"mutable"},"asset-placement":{"$id":"urn:breg:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d","operations":["patch"],"requestEntity":"placement-correction-request","requestRoute":"placement-correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"asset-placement-create-input":{"$id":"urn:breg:entity:asset-placement:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site":{"$id":"urn:breg:entity:asset-site","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"asset-site-create-input":{"$id":"urn:breg:entity:asset-site:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":200,"minLength":0,"type":"string"},"siteCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["siteCode","label"],"type":"object","x-registry-mutationMode":"mutable"},"inspection-event":{"$id":"urn:breg:entity:inspection-event","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"},"inspection-event-create-input":{"$id":"urn:breg:entity:inspection-event:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"observedAt":{"format":"date-time","type":"string"},"result":{"enum":["passed","failed"],"type":"string","x-registry-vocabulary":"inspection-result"}},"required":["asset","observedAt","result"],"type":"object","x-registry-mutationMode":"create_only"},"placement-correction-request":{"$id":"urn:breg:entity:placement-correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"placement-correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"correction-applier","rowBoundaries":[],"targetEntityId":"asset-placement"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d","effects":[{"dependsOn":[],"id":"effect-1","mutations":[{"apiName":"site","field":"site","kind":"set","value":{"field":"proposed-site","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"placement","kind":"existing"},"entity":"asset-placement"}}],"planner":{"kind":"declarative"},"presencePermissions":[{"profileId":"asset-operator","requestRowBoundaries":[],"targetEntityId":"asset-placement"},{"profileId":"site-planner","requestRowBoundaries":[],"targetEntityId":"asset-placement"}],"requestEntity":"placement-correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"correction-supervisor","readableFields":["site"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"asset-placement"},{"profileId":"correction-reviewer","readableFields":["site"],"rowBoundaries":[],"stage":"review","targetEntityId":"asset-placement"}],"stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["asset-placement"]},"x-registry-mutationMode":"mutable"},"placement-correction-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"placement-correction-request-create-input":{"$id":"urn:breg:entity:placement-correction-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-mutationMode":"mutable"},"placement-correction-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"placement-correction-request-revise_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"rebase":{"type":"boolean"}},"required":["rebase"],"type":"object"},"placement-correction-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"asset-site-placement-change-requests","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/assets":{"get":{"operationId":"records.asset-item.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["assetClass","assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"assetCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["assetCode","label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-item.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"create","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/assets/{record_id}":{"get":{"operationId":"records.asset-item.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-item","x-registry-operation":"get","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.asset-item.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-item"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-item"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-operation":"patch","x-registry-responseEntity":"asset-item","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/assets:batch":{"post":{"operationId":"records.asset-item.batch","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"changeContext":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"correction"}},"required":["kind"]},"then":{"required":["reasonCode"]}}],"properties":{"kind":{"enum":["change","correction"],"type":"string"},"reasonCode":{"maxLength":64,"minLength":1,"type":"string","x-registry-maxBytes":64},"reasonText":{"maxLength":4096,"minLength":1,"type":"string","x-registry-maxBytes":4096},"sourceReferences":{"items":{"maxLength":256,"minLength":1,"type":"string","x-registry-maxBytes":256},"maxItems":16,"type":"array"}},"type":"object","x-registry-maxCanonicalBytes":16384},"items":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item-batch-input"},"operation":{"const":"create"}},"required":["operation","data"],"type":"object"},{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"operation":{"const":"patch"},"patch":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"},"recordId":{"format":"uuid","type":"string"}},"required":["operation","recordId","ifMatch","patch"],"type":"object"}]},"maxItems":4,"minItems":1,"type":"array"}},"required":["items"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"results":{"items":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-item"},"etag":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"id":{"format":"uuid","type":"string"},"operation":{"enum":["create","patch"]},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["operation","id","revision","etag","data"],"type":"object"},"maxItems":4,"minItems":1,"type":"array"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["snapshot","results"],"type":"object"}}},"description":"Atomic batch committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-item","x-registry-maximumBytes":16384,"x-registry-maximumItems":4,"x-registry-operation":"batch","x-registry-responseEntity":"asset-item","x-registry-responseShape":"BRegAtomicBatchMutationResponseV1"}},"/v1/records/inspections":{"get":{"operationId":"records.inspection-event.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","observedAt","result"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.inspection-event.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/inspection-event-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"create","x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/inspections/{record_id}":{"get":{"operationId":"records.inspection-event.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/inspection-event"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"inspection-event"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"inspection-event","x-registry-operation":"get","x-registry-responseEntity":"inspection-event","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests":{"get":{"operationId":"records.placement-correction-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer","correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"correction-reviewer":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"correction-reviewer","selectableProperties":["placement","proposedSite","reason"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["placement","proposedSite","reason"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.placement-correction-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/placement-correction-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"create","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests/{record_id}":{"get":{"operationId":"records.placement-correction-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-applier","correction-reviewer","correction-submitter","correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"get","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.placement-correction-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/placement-correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"placement-correction-request"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"patch","x-registry-responseEntity":"placement-correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/apply":{"post":{"operationId":"records.placement-correction-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-applier"],"x-registry-entity":"placement-correction-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.placement-correction-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/revise":{"post":{"operationId":"records.placement-correction-request.request.revise","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-revise_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"revise_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-revise_request-input","method":"post","operation":"revise_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/revise","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/approve":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/reject":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/request-revision":{"post":{"operationId":"records.placement-correction-request.request.stages.final-approval.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-supervisor"],"x-registry-entity":"placement-correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"placement-correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/final-approval/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.placement-correction-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.placement-correction-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.placement-correction-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-reviewer"],"x-registry-entity":"placement-correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"placement-correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/placement-correction-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placement-correction-requests/{record_id}/actions/submit":{"post":{"operationId":"records.placement-correction-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/placement-correction-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["correction-submitter"],"x-registry-entity":"placement-correction-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"placement-correction-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/placement-correction-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"placement-correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["asset-placement"]},"x-registry-responseEntity":"placement-correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/placements":{"get":{"operationId":"records.asset-placement.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-placement.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-placement-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"create","x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placements/{record_id}":{"get":{"operationId":"records.asset-placement.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"get","x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/placements:as-of":{"get":{"operationId":"records.asset-placement.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"as_of","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"as_of","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/placements:current":{"get":{"operationId":"records.asset-placement.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-placement"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-placement"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-placement","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"current","maxPageSize":100,"profile":"asset-operator","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"current","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["asset","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"asset"},{"operators":["equals","in","is_null","is_not_null"],"property":"site"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"site-planner","selectableProperties":["asset","site","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"asset-placement","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/sites":{"get":{"operationId":"records.asset-site.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","correction-submitter","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"asset-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"asset-operator","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"correction-submitter":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"correction-submitter","selectableProperties":["siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null},"site-planner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"siteCode"}],"kind":"list","maxPageSize":100,"profile":"site-planner","selectableProperties":["label","siteCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.asset-site.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/asset-site-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"create","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/sites/{record_id}":{"get":{"operationId":"records.asset-site.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator","site-planner"],"x-registry-entity":"asset-site","x-registry-operation":"get","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.asset-site.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/asset-site"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"asset-site-placement-change-requests"},"entityTypeIdentifier":{"const":"asset-site"},"registryIdentifier":{"const":"asset-site-placement-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["asset-operator"],"x-registry-entity":"asset-site","x-registry-operation":"patch","x-registry-responseEntity":"asset-site","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/asset-site-placement-change-requests/generated/postgres/schema.sql b/products/breg/generated/asset-site-placement-change-requests/generated/postgres/schema.sql index 062580424c..a1eeb05a29 100644 --- a/products/breg/generated/asset-site-placement-change-requests/generated/postgres/schema.sql +++ b/products/breg/generated/asset-site-placement-change-requests/generated/postgres/schema.sql @@ -38,7 +38,7 @@ CREATE POLICY "registry_rls_insert_0ba2346544fb51ffaf62b974" ON registry_data."b CREATE POLICY "registry_rls_select_cd44ded9fa956ec993fdadb4" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_select_0709dccbbcf4373a366b622c" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_8f4439921a2bd33ed05e1c75" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_873f6ac889ddf5223506af33" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_873f6ac889ddf5223506af33" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -47,7 +47,7 @@ CREATE POLICY "registry_cr_rls_select_873f6ac889ddf5223506af33" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_cad186681e91a3c896be6432" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_cad186681e91a3c896be6432" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -73,7 +73,7 @@ CREATE POLICY "registry_cr_rls_select_cad186681e91a3c896be6432" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_0d86fdf1bdef1d6857f9c233" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_0d86fdf1bdef1d6857f9c233" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -99,7 +99,7 @@ CREATE POLICY "registry_cr_rls_select_0d86fdf1bdef1d6857f9c233" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_3cf260b1f3b200bbbc7b4469" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_3cf260b1f3b200bbbc7b4469" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -125,7 +125,7 @@ CREATE POLICY "registry_cr_rls_select_3cf260b1f3b200bbbc7b4469" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_update_b6541a6047cac6654c77e415" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_update_b6541a6047cac6654c77e415" ON registry_data."breg_e_asset_placement_a1ec82196d1fb9aa" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -150,7 +150,7 @@ CREATE POLICY "registry_cr_rls_update_b6541a6047cac6654c77e415" ON registry_data (cr_target.expected_revision IS NULL AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL) OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) - ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL + ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["site"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -242,7 +242,7 @@ CREATE POLICY "registry_rls_select_51b96902b09ed06c4acd79e5" ON registry_data."b AND cr_state.detail_erased_at IS NOT NULL ) )) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb IS NULL); -CREATE POLICY "registry_cr_action_rls_select_16d55fe753ad93ad27a4452a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_16d55fe753ad93ad27a4452a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -250,14 +250,14 @@ CREATE POLICY "registry_cr_action_rls_select_16d55fe753ad93ad27a4452a" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled', 'applied') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_0daeffe283c516528583419c" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_0daeffe283c516528583419c" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -265,7 +265,7 @@ CREATE POLICY "registry_cr_action_rls_update_0daeffe283c516528583419c" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_3b7259601d1d36354c5337d5" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_3b7259601d1d36354c5337d5" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -273,14 +273,14 @@ CREATE POLICY "registry_cr_action_rls_select_3b7259601d1d36354c5337d5" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_dd34d1db5c86feb3f20ce01a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_dd34d1db5c86feb3f20ce01a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -288,7 +288,7 @@ CREATE POLICY "registry_cr_action_rls_update_dd34d1db5c86feb3f20ce01a" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_2a7366ad92d7129dba1b79db" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_2a7366ad92d7129dba1b79db" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -296,14 +296,14 @@ CREATE POLICY "registry_cr_action_rls_select_2a7366ad92d7129dba1b79db" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_6ff48a75b284709fb51cbc78" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_6ff48a75b284709fb51cbc78" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -311,7 +311,7 @@ CREATE POLICY "registry_cr_action_rls_update_6ff48a75b284709fb51cbc78" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_c6a1efebdee883c70c68c42a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_c6a1efebdee883c70c68c42a" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -319,14 +319,14 @@ CREATE POLICY "registry_cr_action_rls_select_c6a1efebdee883c70c68c42a" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_838931b30ad0283f3c3abc8f" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_838931b30ad0283f3c3abc8f" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -334,7 +334,7 @@ CREATE POLICY "registry_cr_action_rls_update_838931b30ad0283f3c3abc8f" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_c940566fdd607b8fec36a613" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_c940566fdd607b8fec36a613" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -342,14 +342,14 @@ CREATE POLICY "registry_cr_action_rls_select_c940566fdd607b8fec36a613" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_0fdae140f8f0c572581c066e" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_0fdae140f8f0c572581c066e" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -357,7 +357,7 @@ CREATE POLICY "registry_cr_action_rls_update_0fdae140f8f0c572581c066e" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_e6ae98b74e7a9da1bcd2f595" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_e6ae98b74e7a9da1bcd2f595" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -365,14 +365,14 @@ CREATE POLICY "registry_cr_action_rls_select_e6ae98b74e7a9da1bcd2f595" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_6315a8cb8085325838df0d40" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_6315a8cb8085325838df0d40" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -380,7 +380,7 @@ CREATE POLICY "registry_cr_action_rls_update_6315a8cb8085325838df0d40" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_fe9959211fb88bc1bd1219ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_fe9959211fb88bc1bd1219ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -388,14 +388,14 @@ CREATE POLICY "registry_cr_action_rls_select_fe9959211fb88bc1bd1219ea" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_5fd588e23dc632d132bc05e4" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_5fd588e23dc632d132bc05e4" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -403,7 +403,7 @@ CREATE POLICY "registry_cr_action_rls_update_5fd588e23dc632d132bc05e4" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_5ff22518d98d971c3237eeb3" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_5ff22518d98d971c3237eeb3" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -411,14 +411,14 @@ CREATE POLICY "registry_cr_action_rls_select_5ff22518d98d971c3237eeb3" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_b868a5af3299adb8f61d8cf6" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_b868a5af3299adb8f61d8cf6" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -426,7 +426,7 @@ CREATE POLICY "registry_cr_action_rls_update_b868a5af3299adb8f61d8cf6" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_aa42e1e2a732d8e12b053b0b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_aa42e1e2a732d8e12b053b0b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -434,14 +434,14 @@ CREATE POLICY "registry_cr_action_rls_select_aa42e1e2a732d8e12b053b0b" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_5266b10ca02c51da1f29f4cf" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_5266b10ca02c51da1f29f4cf" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted', 'approved', 'needs_changes', 'rejected') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -449,7 +449,7 @@ CREATE POLICY "registry_cr_action_rls_update_5266b10ca02c51da1f29f4cf" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted', 'approved', 'needs_changes', 'rejected') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_d7912bde738c83da97c2401b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_d7912bde738c83da97c2401b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -457,7 +457,7 @@ CREATE POLICY "registry_cr_action_rls_select_d7912bde738c83da97c2401b" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_fed5e996f47df8cec4345d34" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_fed5e996f47df8cec4345d34" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -465,7 +465,7 @@ CREATE POLICY "registry_cr_action_rls_update_fed5e996f47df8cec4345d34" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -474,7 +474,7 @@ CREATE POLICY "registry_cr_action_rls_update_fed5e996f47df8cec4345d34" ON regist AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_73f11f5bf9d4643ade6f7a09" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_73f11f5bf9d4643ade6f7a09" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -482,14 +482,14 @@ CREATE POLICY "registry_cr_action_rls_select_73f11f5bf9d4643ade6f7a09" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_a61665e1c021bdbb9673d614" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_a61665e1c021bdbb9673d614" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -497,7 +497,7 @@ CREATE POLICY "registry_cr_action_rls_update_a61665e1c021bdbb9673d614" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_92ae85056ebe30eea42295d9" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_92ae85056ebe30eea42295d9" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -505,14 +505,14 @@ CREATE POLICY "registry_cr_action_rls_select_92ae85056ebe30eea42295d9" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_6e04400c0a9ba503756292ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_6e04400c0a9ba503756292ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -520,7 +520,7 @@ CREATE POLICY "registry_cr_action_rls_update_6e04400c0a9ba503756292ea" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_46eee0a4998899f7f993f088" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_46eee0a4998899f7f993f088" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -528,14 +528,14 @@ CREATE POLICY "registry_cr_action_rls_select_46eee0a4998899f7f993f088" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_b2d47495700b6aac922941a3" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_b2d47495700b6aac922941a3" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -543,7 +543,7 @@ CREATE POLICY "registry_cr_action_rls_update_b2d47495700b6aac922941a3" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_a525b84e8c0e18c6a011f21b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_a525b84e8c0e18c6a011f21b" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -551,14 +551,14 @@ CREATE POLICY "registry_cr_action_rls_select_a525b84e8c0e18c6a011f21b" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_5d29d0cad37ff0b38d5b9fef" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_5d29d0cad37ff0b38d5b9fef" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -566,7 +566,7 @@ CREATE POLICY "registry_cr_action_rls_update_5d29d0cad37ff0b38d5b9fef" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_7fbdabc448d5b81536a440cb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_7fbdabc448d5b81536a440cb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -574,14 +574,14 @@ CREATE POLICY "registry_cr_action_rls_select_7fbdabc448d5b81536a440cb" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_655349f3b3438c6d61619119" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_655349f3b3438c6d61619119" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -589,7 +589,7 @@ CREATE POLICY "registry_cr_action_rls_update_655349f3b3438c6d61619119" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_bfc3fb8c7ba7ed3c189752eb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_bfc3fb8c7ba7ed3c189752eb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -597,14 +597,14 @@ CREATE POLICY "registry_cr_action_rls_select_bfc3fb8c7ba7ed3c189752eb" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_6370e66da945a7954835de96" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_6370e66da945a7954835de96" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'correction-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.placement-correction-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'correction-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-correction-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -612,7 +612,7 @@ CREATE POLICY "registry_cr_action_rls_update_6370e66da945a7954835de96" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_presence_rls_select_1e1a2b97cf1ce6970c5e13bb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-operator' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_presence_rls_select_1e1a2b97cf1ce6970c5e13bb" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'asset-operator' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'asset-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('asset-management') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state JOIN registry_internal.registry_request_proposals AS cr_proposal @@ -630,7 +630,7 @@ CREATE POLICY "registry_cr_presence_rls_select_1e1a2b97cf1ce6970c5e13bb" ON regi AND cr_target.target_entity_id = (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId') AND cr_target.target_record_id = (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId')::uuid ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_presence_rls_select_f43b8a613a7c48feaa2db2ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'site-planner' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_presence_rls_select_f43b8a613a7c48feaa2db2ea" ON registry_data."breg_e_placement_correction_request_adb618290778d325" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'placement-correction-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'asset-placement' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'site-planner' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'site-planner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('site-planning') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state JOIN registry_internal.registry_request_proposals AS cr_proposal diff --git a/products/breg/generated/asset-site-placement-change-requests/generated/schemas/asset-placement.schema.json b/products/breg/generated/asset-site-placement-change-requests/generated/schemas/asset-placement.schema.json index 06ba32f9a5..b8d549cfaf 100644 --- a/products/breg/generated/asset-site-placement-change-requests/generated/schemas/asset-placement.schema.json +++ b/products/breg/generated/asset-site-placement-change-requests/generated/schemas/asset-placement.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce","operations":["patch"],"requestEntity":"placement-correction-request","requestRoute":"placement-correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:asset-placement","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"asset":{"format":"uuid","type":"string"},"site":{"format":"uuid","type":"string"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["asset","site","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d","operations":["patch"],"requestEntity":"placement-correction-request","requestRoute":"placement-correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/asset-site-placement-change-requests/generated/schemas/placement-correction-request.schema.json b/products/breg/generated/asset-site-placement-change-requests/generated/schemas/placement-correction-request.schema.json index eea95e9f4e..147c476553 100644 --- a/products/breg/generated/asset-site-placement-change-requests/generated/schemas/placement-correction-request.schema.json +++ b/products/breg/generated/asset-site-placement-change-requests/generated/schemas/placement-correction-request.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:placement-correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"placement-correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"correction-applier","rowBoundaries":[],"targetEntityId":"asset-placement"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:46e622aa481907c36ff3792941fe8d7d56e7ed6f56c2d112d4181bf4c58fafce","effects":[{"dependsOn":[],"id":"effect-1","mutations":[{"apiName":"site","field":"site","kind":"set","value":{"field":"proposed-site","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"placement","kind":"existing"},"entity":"asset-placement"}}],"planner":{"kind":"declarative"},"presenceGrants":[{"profileId":"asset-operator","requestRowBoundaries":[],"targetEntityId":"asset-placement"},{"profileId":"site-planner","requestRowBoundaries":[],"targetEntityId":"asset-placement"}],"requestEntity":"placement-correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"correction-supervisor","readableFields":["site"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"asset-placement"},{"profileId":"correction-reviewer","readableFields":["site"],"rowBoundaries":[],"stage":"review","targetEntityId":"asset-placement"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["asset-placement"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:placement-correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"placement":{"format":"uuid","type":"string"},"proposedSite":{"format":"uuid","type":"string"},"reason":{"maxLength":1000,"type":"string"}},"required":["placement","proposedSite","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"placement-correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"placement-correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"placement-correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"correction-applier","rowBoundaries":[],"targetEntityId":"asset-placement"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:b42279f66ae69ad3f4aa44e64e31a5b01a0d5daf3d214093eb3b7333127bd03d","effects":[{"dependsOn":[],"id":"effect-1","mutations":[{"apiName":"site","field":"site","kind":"set","value":{"field":"proposed-site","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"placement","kind":"existing"},"entity":"asset-placement"}}],"planner":{"kind":"declarative"},"presencePermissions":[{"profileId":"asset-operator","requestRowBoundaries":[],"targetEntityId":"asset-placement"},{"profileId":"site-planner","requestRowBoundaries":[],"targetEntityId":"asset-placement"}],"requestEntity":"placement-correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"correction-supervisor","readableFields":["site"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"asset-placement"},{"profileId":"correction-reviewer","readableFields":["site"],"rowBoundaries":[],"stage":"review","targetEntityId":"asset-placement"}],"stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["asset-placement"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/authoring/registry-project.schema.json b/products/breg/generated/authoring/registry-project.schema.json index d27454c473..5dc86598a5 100644 --- a/products/breg/generated/authoring/registry-project.schema.json +++ b/products/breg/generated/authoring/registry-project.schema.json @@ -1,12 +1,12 @@ { "$defs": { - "AccessGrantSource": { + "AccessPermissionSource": { "anyOf": [ { - "$ref": "#/$defs/EntityAccessGrantSourceSchema" + "$ref": "#/$defs/EntityAccessPermissionSourceSchema" }, { - "$ref": "#/$defs/ActionAccessGrantSourceSchema" + "$ref": "#/$defs/ActionAccessPermissionSourceSchema" } ] }, @@ -43,7 +43,7 @@ }, "type": "object" }, - "ActionAccessGrantSourceSchema": { + "ActionAccessPermissionSourceSchema": { "additionalProperties": false, "properties": { "action": { @@ -67,7 +67,7 @@ "targets": { "default": [], "items": { - "$ref": "#/$defs/ActionTargetGrantSource" + "$ref": "#/$defs/ActionTargetPermissionSource" }, "type": "array" } @@ -563,7 +563,7 @@ ], "type": "object" }, - "ActionTargetGrantSource": { + "ActionTargetPermissionSource": { "additionalProperties": false, "properties": { "entity": { @@ -619,7 +619,15 @@ }, "type": "object" }, - "ApplyTargetGrantSource": { + "ActorKindSource": { + "enum": [ + "human", + "agent", + "service" + ], + "type": "string" + }, + "ApplyTargetPermissionSource": { "additionalProperties": false, "properties": { "entity": { @@ -1771,7 +1779,7 @@ ], "type": "object" }, - "EntityAccessGrantSourceSchema": { + "EntityAccessPermissionSourceSchema": { "additionalProperties": false, "properties": { "allowCount": { @@ -1785,7 +1793,7 @@ "applyTargets": { "default": [], "items": { - "$ref": "#/$defs/ApplyTargetGrantSource" + "$ref": "#/$defs/ApplyTargetPermissionSource" }, "type": "array" }, @@ -1803,7 +1811,7 @@ "lookups": { "default": [], "items": { - "$ref": "#/$defs/LookupGrantSource" + "$ref": "#/$defs/LookupPermissionSource" }, "type": "array" }, @@ -1824,7 +1832,7 @@ "readPaths": { "default": [], "items": { - "$ref": "#/$defs/ReadPathGrantSource" + "$ref": "#/$defs/ReadPathPermissionSource" }, "type": "array" }, @@ -1847,7 +1855,7 @@ "requestPresence": { "default": [], "items": { - "$ref": "#/$defs/RequestPresenceGrantSource" + "$ref": "#/$defs/RequestPresencePermissionSource" }, "type": "array" }, @@ -1865,7 +1873,7 @@ "reviewStages": { "default": [], "items": { - "$ref": "#/$defs/ReviewStageGrantSource" + "$ref": "#/$defs/ReviewStagePermissionSource" }, "type": "array" }, @@ -1890,7 +1898,7 @@ "spatialQueries": { "anyOf": [ { - "$ref": "#/$defs/SpatialQueryGrantSource" + "$ref": "#/$defs/SpatialQueryPermissionSource" }, { "type": "null" @@ -2381,7 +2389,7 @@ ], "type": "object" }, - "LookupGrantSource": { + "LookupPermissionSource": { "additionalProperties": false, "properties": { "claimMapping": { @@ -3131,6 +3139,16 @@ "ProjectAccessProfileSource": { "additionalProperties": false, "properties": { + "actorKind": { + "anyOf": [ + { + "$ref": "#/$defs/ActorKindSource" + }, + { + "type": "null" + } + ] + }, "anonymous": { "default": false, "type": "boolean" @@ -3139,16 +3157,16 @@ "default": false, "type": "boolean" }, - "grants": { + "id": { + "type": "string" + }, + "permissions": { "default": [], "items": { - "$ref": "#/$defs/AccessGrantSource" + "$ref": "#/$defs/AccessPermissionSource" }, "type": "array" }, - "id": { - "type": "string" - }, "principalClaim": { "default": null, "type": [ @@ -3156,6 +3174,13 @@ "null" ] }, + "requesterClients": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, "requiredPurposes": { "default": [], "description": "The verified token's purpose must match one listed value. Empty means no purpose restriction.", @@ -3173,6 +3198,16 @@ }, "type": "array", "uniqueItems": true + }, + "taskGrant": { + "anyOf": [ + { + "$ref": "#/$defs/TaskGrantSource" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3180,7 +3215,7 @@ ], "type": "object" }, - "ReadPathGrantSource": { + "ReadPathPermissionSource": { "additionalProperties": false, "properties": { "allowCount": { @@ -3339,7 +3374,7 @@ ], "type": "string" }, - "RequestPresenceGrantSource": { + "RequestPresencePermissionSource": { "additionalProperties": false, "properties": { "requestType": { @@ -3368,7 +3403,7 @@ } ] }, - "ReviewStageGrantSource": { + "ReviewStagePermissionSource": { "additionalProperties": false, "properties": { "stage": { @@ -3377,7 +3412,7 @@ "targets": { "default": [], "items": { - "$ref": "#/$defs/ReviewStageTargetGrantSource" + "$ref": "#/$defs/ReviewStageTargetPermissionSource" }, "type": "array" } @@ -3387,7 +3422,7 @@ ], "type": "object" }, - "ReviewStageTargetGrantSource": { + "ReviewStageTargetPermissionSource": { "additionalProperties": false, "properties": { "entity": { @@ -3460,7 +3495,7 @@ ], "type": "object" }, - "SpatialBboxGrantSource": { + "SpatialBboxPermissionSource": { "additionalProperties": false, "properties": { "maximumLatitudeSpanDegrees": { @@ -3476,13 +3511,13 @@ ], "type": "object" }, - "SpatialQueryGrantSource": { + "SpatialQueryPermissionSource": { "additionalProperties": false, "properties": { "bbox": { "anyOf": [ { - "$ref": "#/$defs/SpatialBboxGrantSource" + "$ref": "#/$defs/SpatialBboxPermissionSource" }, { "type": "null" @@ -3615,6 +3650,22 @@ ], "type": "object" }, + "TaskGrantSource": { + "additionalProperties": false, + "properties": { + "authority": { + "type": "string" + }, + "sourceIssuer": { + "type": "string" + } + }, + "required": [ + "authority", + "sourceIssuer" + ], + "type": "object" + }, "TemporalSource": { "additionalProperties": false, "properties": { diff --git a/products/breg/generated/household-contact-actions/compiled/actions.json b/products/breg/generated/household-contact-actions/compiled/actions.json index 448e314092..464e3ed02f 100644 --- a/products/breg/generated/household-contact-actions/compiled/actions.json +++ b/products/breg/generated/household-contact-actions/compiled/actions.json @@ -1 +1 @@ -{"access":[{"actionId":"register-household-contact","defaultProfileId":"contact-registrar","operation":"invoke","profileIds":["contact-registrar"],"routeId":"actions.register-household-contact.invoke"},{"actionId":"register-household-contact","defaultProfileId":"contact-registrar","operation":"invoke","profileIds":["contact-registrar"],"routeId":"actions.register-household-contact.target_conditions"}],"actions":[{"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"district","kind":"set","value":{"input":"district","kind":"fromInput"}},{"field":"legal-name","kind":"set","value":{"input":"legal-name","kind":"fromInput"}},{"field":"person-code","kind":"set","value":{"input":"person-code","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}},{"dependsOn":["person"],"id":"household","mutations":[{"field":"contact-person","kind":"set","value":{"effect":"person","kind":"fromEffect","target_entity_id":"person"}}],"operation":"patch","target":{"binding":{"input":"household","kind":"existing"},"entityId":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"field":"district","kind":"set","value":{"input":"district","kind":"fromInput"}},{"field":"household","kind":"set","value":{"input":"household","kind":"fromInput"}},{"field":"person","kind":"set","value":{"effect":"person","kind":"fromEffect","target_entity_id":"person"}},{"field":"registration-site","kind":"set","value":{"input":"registration-site","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"group-membership"}}],"grants":[{"anonymous":false,"default":false,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"contact-registrar","requiredPurposes":["contact-registration"],"requiredScopes":["registry:contact:register"],"results":["household","membership","person"],"targets":[{"entityId":"group-membership","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"household","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"person","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"service-center","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]}]}],"id":"register-household-contact","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"resultEffects":["household","membership","person"],"route":"/v1/actions/register-household-contact","sourceModule":"household-contact-actions-core","targetUses":[{"conditionRequired":false,"entityId":"group-membership","fields":["district","household","person","registration-site"],"operation":"create","source":{"effect":"membership","kind":"effect"}},{"conditionRequired":true,"entityId":"household","fields":["contact-person"],"operation":"patch","source":{"input":"household","kind":"input"}},{"conditionRequired":false,"entityId":"person","fields":["district","legal-name","person-code"],"operation":"create","source":{"effect":"person","kind":"effect"}},{"conditionRequired":false,"entityId":"service-center","fields":[],"operation":"invoke","source":{"input":"registration-site","kind":"input"}}]}],"routes":[{"accessProfiles":["contact-registrar"],"actionId":"register-household-contact","defaultAccessProfile":"contact-registrar","id":"actions.register-household-contact.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-household-contact"},{"accessProfiles":["contact-registrar"],"actionId":"register-household-contact","defaultAccessProfile":"contact-registrar","id":"actions.register-household-contact.target_conditions","kind":"target_conditions","method":"POST","operation":"invoke","path":"/v1/actions/register-household-contact/target-conditions"}]} \ No newline at end of file +{"access":[{"actionId":"register-household-contact","defaultProfileId":"contact-registrar","operation":"invoke","profileIds":["contact-registrar"],"routeId":"actions.register-household-contact.invoke"},{"actionId":"register-household-contact","defaultProfileId":"contact-registrar","operation":"invoke","profileIds":["contact-registrar"],"routeId":"actions.register-household-contact.target_conditions"}],"actions":[{"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"district","kind":"set","value":{"input":"district","kind":"fromInput"}},{"field":"legal-name","kind":"set","value":{"input":"legal-name","kind":"fromInput"}},{"field":"person-code","kind":"set","value":{"input":"person-code","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}},{"dependsOn":["person"],"id":"household","mutations":[{"field":"contact-person","kind":"set","value":{"effect":"person","kind":"fromEffect","target_entity_id":"person"}}],"operation":"patch","target":{"binding":{"input":"household","kind":"existing"},"entityId":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"field":"district","kind":"set","value":{"input":"district","kind":"fromInput"}},{"field":"household","kind":"set","value":{"input":"household","kind":"fromInput"}},{"field":"person","kind":"set","value":{"effect":"person","kind":"fromEffect","target_entity_id":"person"}},{"field":"registration-site","kind":"set","value":{"input":"registration-site","kind":"fromInput"}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"group-membership"}}],"id":"register-household-contact","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"permissions":[{"anonymous":false,"default":false,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"contact-registrar","requiredPurposes":["contact-registration"],"requiredScopes":["registry:contact:register"],"results":["household","membership","person"],"targets":[{"entityId":"group-membership","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"household","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"person","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]},{"entityId":"service-center","rowBoundaries":[{"claim":"district","field":"district","operator":"equals"}]}]}],"resultEffects":["household","membership","person"],"route":"/v1/actions/register-household-contact","sourceModule":"household-contact-actions-core","targetUses":[{"conditionRequired":false,"entityId":"group-membership","fields":["district","household","person","registration-site"],"operation":"create","source":{"effect":"membership","kind":"effect"}},{"conditionRequired":true,"entityId":"household","fields":["contact-person"],"operation":"patch","source":{"input":"household","kind":"input"}},{"conditionRequired":false,"entityId":"person","fields":["district","legal-name","person-code"],"operation":"create","source":{"effect":"person","kind":"effect"}},{"conditionRequired":false,"entityId":"service-center","fields":[],"operation":"invoke","source":{"input":"registration-site","kind":"input"}}]}],"routes":[{"accessProfiles":["contact-registrar"],"actionId":"register-household-contact","defaultAccessProfile":"contact-registrar","id":"actions.register-household-contact.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-household-contact"},{"accessProfiles":["contact-registrar"],"actionId":"register-household-contact","defaultAccessProfile":"contact-registrar","id":"actions.register-household-contact.target_conditions","kind":"target_conditions","method":"POST","operation":"invoke","path":"/v1/actions/register-household-contact/target-conditions"}]} \ No newline at end of file diff --git a/products/breg/generated/household-contact-actions/generated/metadata/registry.json b/products/breg/generated/household-contact-actions/generated/metadata/registry.json index ed252cb570..319ad4febd 100644 --- a/products/breg/generated/household-contact-actions/generated/metadata/registry.json +++ b/products/breg/generated/household-contact-actions/generated/metadata/registry.json @@ -1 +1 @@ -{"actions":[{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}}],"entities":[{"entries":[{"accessProfile":"household-operator","operation":"get","readableFields":["district","household","person","registration-site"],"responseEntityId":"group-membership","routeId":"records.group-membership.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["district","household","person","registration-site"],"responseEntityId":"group-membership","routeId":"records.group-membership.list"}],"id":"group-membership","route":"group-memberships","schemaPath":"/v1/schemas/group-membership"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.create"},{"accessProfile":"household-maintainer","operation":"get","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-operator","operation":"get","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-maintainer","operation":"patch","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.patch"}],"id":"household","route":"households","schemaPath":"/v1/schemas/household"},{"entries":[{"accessProfile":"household-operator","operation":"get","readableFields":["district","legal-name","person-code"],"responseEntityId":"person","routeId":"records.person.get"}],"id":"person","route":"persons","schemaPath":"/v1/schemas/person"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["center-code","district","label"],"responseEntityId":"service-center","routeId":"records.service-center.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["center-code","district","label"],"responseEntityId":"service-center","routeId":"records.service-center.get"}],"id":"service-center","route":"service-centers","schemaPath":"/v1/schemas/service-center"}],"registryId":"household-contact-actions","version":"0.1.0"} \ No newline at end of file +{"actions":[{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}}],"entities":[{"entries":[{"accessProfile":"household-operator","operation":"get","readableFields":["district","household","person","registration-site"],"responseEntityId":"group-membership","routeId":"records.group-membership.get"},{"accessProfile":"household-operator","operation":"list","readableFields":["district","household","person","registration-site"],"responseEntityId":"group-membership","routeId":"records.group-membership.list"}],"id":"group-membership","route":"group-memberships","schemaPath":"/v1/schemas/group-membership"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.create"},{"accessProfile":"household-maintainer","operation":"get","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-operator","operation":"get","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.get"},{"accessProfile":"household-maintainer","operation":"patch","readableFields":["contact-person","district","household-code","household-name"],"responseEntityId":"household","routeId":"records.household.patch"}],"id":"household","route":"households","schemaPath":"/v1/schemas/household"},{"entries":[{"accessProfile":"household-operator","operation":"get","readableFields":["district","legal-name","person-code"],"responseEntityId":"person","routeId":"records.person.get"}],"id":"person","route":"persons","schemaPath":"/v1/schemas/person"},{"entries":[{"accessProfile":"household-operator","operation":"create","readableFields":["center-code","district","label"],"responseEntityId":"service-center","routeId":"records.service-center.create"},{"accessProfile":"household-operator","operation":"get","readableFields":["center-code","district","label"],"responseEntityId":"service-center","routeId":"records.service-center.get"}],"id":"service-center","route":"service-centers","schemaPath":"/v1/schemas/service-center"}],"registryId":"household-contact-actions","version":"0.1.0"} \ No newline at end of file diff --git a/products/breg/generated/household-contact-actions/generated/openapi.json b/products/breg/generated/household-contact-actions/generated/openapi.json index 1a1b91ed80..3daf8acc3d 100644 --- a/products/breg/generated/household-contact-actions/generated/openapi.json +++ b/products/breg/generated/household-contact-actions/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-household-contact-invoke-input":{"$id":"urn:breg:action:register-household-contact:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"contactName":{"maxLength":160,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"householdId":{"format":"uuid","type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"serviceCenterId":{"format":"uuid","type":"string"}},"required":["householdId","serviceCenterId","personCode","contactName","district"],"type":"object"},"preconditions":{"additionalProperties":false,"properties":{"householdId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["householdId"],"type":"object"}},"required":["input","preconditions"],"type":"object","x-registry-action":"register-household-contact","x-registry-requiredConditionKeys":["householdId"]},"action-register-household-contact-invoke-response":{"$id":"urn:breg:action:register-household-contact:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-household-contact"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"household":{"additionalProperties":false,"properties":{"entity":{"const":"household"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"membership":{"additionalProperties":false,"properties":{"entity":{"const":"group-membership"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":["household","membership","person"],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-household-contact-target-conditions-input":{"$id":"urn:breg:action:register-household-contact:target-conditions-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"householdId":{"format":"uuid","type":"string"}},"required":["householdId"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-household-contact","x-registry-requiredConditionKeys":["householdId"]},"action-register-household-contact-target-conditions-response":{"$id":"urn:breg:action:register-household-contact:target-conditions-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"preconditions":{"additionalProperties":false,"properties":{"householdId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["householdId"],"type":"object"}},"required":["preconditions"],"type":"object","x-registry-action":"register-household-contact"},"group-membership":{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"registrationSite":{"format":"uuid","type":"string"}},"required":["person","household","registrationSite","district"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"district":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"}},"required":["householdCode","householdName","district"],"type":"object","x-registry-mutationMode":"mutable"},"household-create-input":{"$id":"urn:breg:entity:household:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"}},"required":["householdCode","householdName","district"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","legalName","district"],"type":"object","x-registry-mutationMode":"mutable"},"service-center":{"$id":"urn:breg:entity:service-center","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"centerCode":{"maxLength":64,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":160,"minLength":0,"type":"string"}},"required":["centerCode","label","district"],"type":"object","x-registry-mutationMode":"mutable"},"service-center-create-input":{"$id":"urn:breg:entity:service-center:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"centerCode":{"maxLength":64,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":160,"minLength":0,"type":"string"}},"required":["centerCode","label","district"],"type":"object","x-registry-mutationMode":"mutable"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"household-contact-actions","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-household-contact":{"post":{"operationId":"actions.register-household-contact.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["contact-registrar"],"x-registry-action":{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}},"x-registry-defaultAccessProfile":"contact-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-household-contact/target-conditions":{"post":{"operationId":"actions.register-household-contact.target_conditions","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-target-conditions-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-target-conditions-response"}}},"description":"Action target conditions returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["contact-registrar"],"x-registry-action":{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}},"x-registry-defaultAccessProfile":"contact-registrar","x-registry-operation":"invoke","x-registry-routeKind":"target_conditions"}},"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["district","household","person","registrationSite"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households":{"post":{"operationId":"records.household.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-maintainer","household-operator"],"x-registry-entity":"household","x-registry-operation":"get","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.household.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-maintainer"],"x-registry-entity":"household","x-registry-operation":"patch","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/service-centers":{"post":{"operationId":"records.service-center.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/service-center-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"service-center","x-registry-operation":"create","x-registry-responseEntity":"service-center","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/service-centers/{record_id}":{"get":{"operationId":"records.service-center.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"service-center","x-registry-operation":"get","x-registry-responseEntity":"service-center","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-household-contact-invoke-input":{"$id":"urn:breg:action:register-household-contact:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"contactName":{"maxLength":160,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"householdId":{"format":"uuid","type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"serviceCenterId":{"format":"uuid","type":"string"}},"required":["householdId","serviceCenterId","personCode","contactName","district"],"type":"object"},"preconditions":{"additionalProperties":false,"properties":{"householdId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["householdId"],"type":"object"}},"required":["input","preconditions"],"type":"object","x-registry-action":"register-household-contact","x-registry-requiredConditionKeys":["householdId"]},"action-register-household-contact-invoke-response":{"$id":"urn:breg:action:register-household-contact:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-household-contact"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"household":{"additionalProperties":false,"properties":{"entity":{"const":"household"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"membership":{"additionalProperties":false,"properties":{"entity":{"const":"group-membership"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":["household","membership","person"],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-household-contact-target-conditions-input":{"$id":"urn:breg:action:register-household-contact:target-conditions-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"householdId":{"format":"uuid","type":"string"}},"required":["householdId"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-household-contact","x-registry-requiredConditionKeys":["householdId"]},"action-register-household-contact-target-conditions-response":{"$id":"urn:breg:action:register-household-contact:target-conditions-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"preconditions":{"additionalProperties":false,"properties":{"householdId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["householdId"],"type":"object"}},"required":["preconditions"],"type":"object","x-registry-action":"register-household-contact"},"group-membership":{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"registrationSite":{"format":"uuid","type":"string"}},"required":["person","household","registrationSite","district"],"type":"object","x-registry-mutationMode":"mutable"},"household":{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"district":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"}},"required":["householdCode","householdName","district"],"type":"object","x-registry-mutationMode":"mutable"},"household-create-input":{"$id":"urn:breg:entity:household:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"}},"required":["householdCode","householdName","district"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"district":{"maxLength":80,"minLength":0,"type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","legalName","district"],"type":"object","x-registry-mutationMode":"mutable"},"service-center":{"$id":"urn:breg:entity:service-center","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"centerCode":{"maxLength":64,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":160,"minLength":0,"type":"string"}},"required":["centerCode","label","district"],"type":"object","x-registry-mutationMode":"mutable"},"service-center-create-input":{"$id":"urn:breg:entity:service-center:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"centerCode":{"maxLength":64,"minLength":0,"type":"string"},"district":{"maxLength":80,"minLength":0,"type":"string"},"label":{"maxLength":160,"minLength":0,"type":"string"}},"required":["centerCode","label","district"],"type":"object","x-registry-mutationMode":"mutable"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"household-contact-actions","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-household-contact":{"post":{"operationId":"actions.register-household-contact.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["contact-registrar"],"x-registry-action":{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}},"x-registry-defaultAccessProfile":"contact-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-household-contact/target-conditions":{"post":{"operationId":"actions.register-household-contact.target_conditions","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-target-conditions-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-household-contact-target-conditions-response"}}},"description":"Action target conditions returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["contact-registrar"],"x-registry-action":{"access":{"accessProfiles":["contact-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-household-contact/target-conditions","contractFingerprint":"sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf","id":"register-household-contact","inputMode":"fixed","inputs":[{"apiName":"householdId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"household","type":"reference"},"id":"household","nullable":false,"required":true},{"apiName":"serviceCenterId","classification":"restricted","fieldType":{"onDelete":"restrict","target":"service-center","type":"reference"},"id":"registration-site","nullable":false,"required":true},{"apiName":"personCode","classification":"restricted","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"person-code","nullable":false,"required":true},{"apiName":"contactName","classification":"restricted","fieldType":{"maxLength":160,"minLength":0,"type":"string"},"id":"legal-name","nullable":false,"required":true},{"apiName":"district","classification":"restricted","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"district","nullable":false,"required":true}],"maximumInputStringBytes":null,"referenceInputs":[{"apiName":"householdId","input":"household","targetEntity":"household"},{"apiName":"serviceCenterId","input":"registration-site","targetEntity":"service-center"}],"requiredConditionKeys":["householdId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"household","entity":"household","operation":"patch"},{"effect":"membership","entity":"group-membership","operation":"create"}],"route":"/v1/actions/register-household-contact","routes":{"invoke":{"inputSchema":"action-register-household-contact-invoke-input","method":"POST","operationId":"actions.register-household-contact.invoke","path":"/v1/actions/register-household-contact","requiresIdempotencyKey":true,"responseSchema":"action-register-household-contact-invoke-response"},"targetConditions":{"inputSchema":"action-register-household-contact-target-conditions-input","method":"POST","operationId":"actions.register-household-contact.target_conditions","path":"/v1/actions/register-household-contact/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-household-contact-target-conditions-response"}}},"x-registry-defaultAccessProfile":"contact-registrar","x-registry-operation":"invoke","x-registry-routeKind":"target_conditions"}},"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["district","household","person","registrationSite"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households":{"post":{"operationId":"records.household.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-maintainer","household-operator"],"x-registry-entity":"household","x-registry-operation":"get","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.household.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-maintainer"],"x-registry-entity":"household","x-registry-operation":"patch","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/service-centers":{"post":{"operationId":"records.service-center.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/service-center-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"service-center","x-registry-operation":"create","x-registry-responseEntity":"service-center","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/service-centers/{record_id}":{"get":{"operationId":"records.service-center.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/service-center"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-contact-actions"},"entityTypeIdentifier":{"const":"service-center"},"registryIdentifier":{"const":"household-contact-actions"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"service-center","x-registry-operation":"get","x-registry-responseEntity":"service-center","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/household-contact-actions/generated/postgres/schema.sql b/products/breg/generated/household-contact-actions/generated/postgres/schema.sql index aeddf85001..f55759a033 100644 --- a/products/breg/generated/household-contact-actions/generated/postgres/schema.sql +++ b/products/breg/generated/household-contact-actions/generated/postgres/schema.sql @@ -25,28 +25,28 @@ ALTER TABLE registry_data."breg_e_service_center_22d9ca8660104fdc" ADD CONSTRAIN ALTER TABLE registry_data."breg_e_group_membership_a576b1bf8befd33b" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_group_membership_a576b1bf8befd33b" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_f806157c782b738f1aa72af5" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_group_membership_district_133d0ccf88fb7cb4" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_8593a53b8bdd1cc3029ffcd4" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'membership' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('membership') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('membership', 'district'), ('membership', 'household'), ('membership', 'person'), ('membership', 'registration-site')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_group_membership_district_133d0ccf88fb7cb4" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_504820f75d3360e2bc2b5322" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'membership' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('membership') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('membership', 'district'), ('membership', 'household'), ('membership', 'person'), ('membership', 'registration-site')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_group_membership_district_133d0ccf88fb7cb4" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_8593a53b8bdd1cc3029ffcd4" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'membership' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('membership') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('membership', 'district'), ('membership', 'household'), ('membership', 'person'), ('membership', 'registration-site')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_group_membership_district_133d0ccf88fb7cb4" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_504820f75d3360e2bc2b5322" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'membership' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('membership') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('membership', 'district'), ('membership', 'household'), ('membership', 'person'), ('membership', 'registration-site')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_group_membership_district_133d0ccf88fb7cb4" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); ALTER TABLE registry_data."breg_e_household_99c2dbe1ce1a9259" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_household_99c2dbe1ce1a9259" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_6ee4738d9d9370b6fac9828f" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-maintainer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_update_dacb3e795f7a9056cf775e7a" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-maintainer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-maintainer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_d34f6f0be055972f57e8c1cb" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_0114d32c4eff36a73fdc0507" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_select_400c11ca8c5d5b62ceaafce1" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_update_4f670b982b69ba9fb47b5083" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' AND record_revision = (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); -CREATE POLICY "registry_action_rls_lock_update_e3aade261e252f6002ce4e75" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'true'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') WITH CHECK (false); +CREATE POLICY "registry_action_rls_select_400c11ca8c5d5b62ceaafce1" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_update_4f670b982b69ba9fb47b5083" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' AND record_revision = (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_lock_update_e3aade261e252f6002ce4e75" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'household' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('household') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('household', 'contact-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_household_district_7d86868f8746ea4e" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'true'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') WITH CHECK (false); ALTER TABLE registry_data."breg_e_person_6ebfb4811e7ddd30" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_person_6ebfb4811e7ddd30" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_bec3cfd3df730b6ac35ac67e" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_person_district_07fd98df6f1427b1" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_3fc32558c14aeebe1c8649ad" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'district'), ('person', 'legal-name'), ('person', 'person-code')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_person_district_07fd98df6f1427b1" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_7e5f5669d46f35ae9ef14fda" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'district'), ('person', 'legal-name'), ('person', 'person-code')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_person_district_07fd98df6f1427b1" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_3fc32558c14aeebe1c8649ad" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'district'), ('person', 'legal-name'), ('person', 'person-code')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_person_district_07fd98df6f1427b1" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_7e5f5669d46f35ae9ef14fda" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'district'), ('person', 'legal-name'), ('person', 'person-code')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_person_district_07fd98df6f1427b1" = ((((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); ALTER TABLE registry_data."breg_e_service_center_22d9ca8660104fdc" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_service_center_22d9ca8660104fdc" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_26e53735cede8f859aef5c69" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_350d8b0e91dc9a234a9abe06" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'district' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::text) AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_link_rls_select_60cfde0e63450524bb8d572a" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'inputId' = 'registration-site' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetEntityId' = 'service-center' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'operation' = 'invoke' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = ((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_link_rls_lock_update_c5b54116336973692e10af82" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:bfaf04ae367df160c26a275c492668444fde77a6e9babfc3ab61bd2f745d9706' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'inputId' = 'registration-site' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetEntityId' = 'service-center' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'operation' = 'invoke' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = ((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active') WITH CHECK (false); +CREATE POLICY "registry_action_link_rls_select_60cfde0e63450524bb8d572a" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'inputId' = 'registration-site' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetEntityId' = 'service-center' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'operation' = 'invoke' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = ((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_link_rls_lock_update_c5b54116336973692e10af82" ON registry_data."breg_e_service_center_22d9ca8660104fdc" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'actionId' = 'register-household-contact' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:9c0929d00ae1ddd06171e3b67504a9baef39c9cef7c04f3be61415ecdd3356cf' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'contact-registrar' AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'inputId' = 'registration-site' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetEntityId' = 'service-center' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'operation' = 'invoke' AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries')) = 1 AND jsonb_typeof(((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0)) = 'object' AND (((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'field' = 'district' AND ((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) ->> 'operator' = 'equals' AND jsonb_typeof((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 'array' AND jsonb_array_length((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values')) = 1 AND "breg_f_service_center_district_9cc24a9db7de7c35" = ((((NULLIF(current_setting('registry.immediate_action_link_context', true), '')::jsonb -> 'targetRowBoundaries') -> 0) -> 'values') ->> 0)::text AND record_lifecycle = 'active') WITH CHECK (false); CREATE VIEW registry_source."group_membership" WITH (security_invoker=true, security_barrier=true) AS SELECT record_id AS "id", "breg_f_group_membership_person_b4d3c53f32217ad4" AS "person", "breg_f_group_membership_household_8cbe77251d9392e6" AS "household", "breg_f_group_membership_registration_site_55d7b7ae278172eb" AS "registration_site", "breg_f_group_membership_district_133d0ccf88fb7cb4" AS "district" diff --git a/products/breg/generated/person-name-change-rhai/generated/openapi.json b/products/breg/generated/person-name-change-rhai/generated/openapi.json index fd323ae4a7..8d87d4c35f 100644 --- a/products/breg/generated/person-name-change-rhai/generated/openapi.json +++ b/products/breg/generated/person-name-change-rhai/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5","operations":["patch"],"requestEntity":"person-name-change-request","requestRoute":"person-name-change-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"person-create-input":{"$id":"urn:breg:entity:person:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"person-name-change-request":{"$id":"urn:breg:entity:person-name-change-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"person-name-change-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null}],"application":{"allowedDispositions":["apply","queue"],"mode":"planner","queueReasons":[{"code":"assisted-review","label":"Assisted review requested by synthetic handling."}]},"applyGrants":[{"profileId":"assisted-applier","rowBoundaries":[],"targetEntityId":"person"},{"profileId":"name-change-submitter","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5","effects":[],"planner":{"abi":"registry.change-request-plan/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumFieldMutations":128,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSnapshotBytes":2097152,"maximumSourceBytes":65536,"maximumStringBytes":16384,"maximumTargets":16},"possibleWrites":[{"fields":["display-name"],"operation":"patch","target":{"fromField":"person"}}]},"presenceGrants":[],"requestEntity":"person-name-change-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[],"reviewMode":"none","stages":[],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["person"]},"x-registry-mutationMode":"mutable"},"person-name-change-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"person-name-change-request-create-input":{"$id":"urn:breg:entity:person-name-change-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-mutationMode":"mutable"},"person-name-change-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"person-name-change-rhai","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/person-name-change-requests":{"get":{"operationId":"records.person-name-change-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier"],"x-registry-entity":"person-name-change-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"assisted-applier":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"assisted-applier","selectableProperties":["familyName","givenName","handling","person"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.person-name-change-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-name-change-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"create","x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/person-name-change-requests/{record_id}":{"get":{"operationId":"records.person-name-change-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier","name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"get","x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/person-name-change-requests/{record_id}/actions/apply":{"post":{"operationId":"records.person-name-change-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/person-name-change-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier","name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"person-name-change-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/person-name-change-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"person-name-change-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["person"]},"x-registry-responseEntity":"person-name-change-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/person-name-change-requests/{record_id}/actions/submit":{"post":{"operationId":"records.person-name-change-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/person-name-change-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"person-name-change-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/person-name-change-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"person-name-change-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["person"]},"x-registry-responseEntity":"person-name-change-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"person-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"person-operator","selectableProperties":["displayName","personCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.person.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"create","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35","operations":["patch"],"requestEntity":"person-name-change-request","requestRoute":"person-name-change-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"person-create-input":{"$id":"urn:breg:entity:person:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"person-name-change-request":{"$id":"urn:breg:entity:person-name-change-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"person-name-change-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null}],"application":{"allowedDispositions":["apply","queue"],"mode":"planner","queueReasons":[{"code":"assisted-review","label":"Assisted review requested by synthetic handling."}]},"applyPermissions":[{"profileId":"assisted-applier","rowBoundaries":[],"targetEntityId":"person"},{"profileId":"name-change-submitter","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35","effects":[],"planner":{"abi":"registry.change-request-plan/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumFieldMutations":128,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSnapshotBytes":2097152,"maximumSourceBytes":65536,"maximumStringBytes":16384,"maximumTargets":16},"possibleWrites":[{"fields":["display-name"],"operation":"patch","target":{"fromField":"person"}}]},"presencePermissions":[],"requestEntity":"person-name-change-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"none","reviewPermissions":[],"stages":[],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["person"]},"x-registry-mutationMode":"mutable"},"person-name-change-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"person-name-change-request-create-input":{"$id":"urn:breg:entity:person-name-change-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-mutationMode":"mutable"},"person-name-change-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"person-name-change-rhai","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/person-name-change-requests":{"get":{"operationId":"records.person-name-change-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier"],"x-registry-entity":"person-name-change-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"assisted-applier":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"assisted-applier","selectableProperties":["familyName","givenName","handling","person"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.person-name-change-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-name-change-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"create","x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/person-name-change-requests/{record_id}":{"get":{"operationId":"records.person-name-change-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/person-name-change-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person-name-change-request"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier","name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"get","x-registry-responseEntity":"person-name-change-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/person-name-change-requests/{record_id}/actions/apply":{"post":{"operationId":"records.person-name-change-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/person-name-change-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["assisted-applier","name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"person-name-change-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/person-name-change-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"person-name-change-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["person"]},"x-registry-responseEntity":"person-name-change-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/person-name-change-requests/{record_id}/actions/submit":{"post":{"operationId":"records.person-name-change-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/person-name-change-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["name-change-submitter"],"x-registry-entity":"person-name-change-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"person-name-change-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/person-name-change-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"person-name-change-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["person"]},"x-registry-responseEntity":"person-name-change-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"person-operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"person-operator","selectableProperties":["displayName","personCode"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.person.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"create","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-name-change-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-name-change-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/person-name-change-rhai/generated/postgres/schema.sql b/products/breg/generated/person-name-change-rhai/generated/postgres/schema.sql index 485f07d5e4..7f543b2a4b 100644 --- a/products/breg/generated/person-name-change-rhai/generated/postgres/schema.sql +++ b/products/breg/generated/person-name-change-rhai/generated/postgres/schema.sql @@ -18,7 +18,7 @@ ALTER TABLE registry_data."breg_e_person_6ebfb4811e7ddd30" ENABLE ROW LEVEL SECU ALTER TABLE registry_data."breg_e_person_6ebfb4811e7ddd30" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_0cd50b9a0164f7e665a8a5bf" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_b0c69437aeeeeed36df45cb4" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_5352350dbb35725b1902f1b8" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_5352350dbb35725b1902f1b8" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -27,7 +27,7 @@ CREATE POLICY "registry_cr_rls_select_5352350dbb35725b1902f1b8" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_f445be63c4d0318f41f693c4" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_f445be63c4d0318f41f693c4" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -53,7 +53,7 @@ CREATE POLICY "registry_cr_rls_select_f445be63c4d0318f41f693c4" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_update_ae9880031739397f798c3f33" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_update_ae9880031739397f798c3f33" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -78,7 +78,7 @@ CREATE POLICY "registry_cr_rls_update_ae9880031739397f798c3f33" ON registry_data (cr_target.expected_revision IS NULL AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL) OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) - ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL + ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -104,7 +104,7 @@ CREATE POLICY "registry_cr_rls_update_ae9880031739397f798c3f33" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_8f170223ca269199aead21d5" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_8f170223ca269199aead21d5" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -130,7 +130,7 @@ CREATE POLICY "registry_cr_rls_select_8f170223ca269199aead21d5" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_update_77670e7f14316afe5c99ffee" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_update_77670e7f14316afe5c99ffee" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -155,7 +155,7 @@ CREATE POLICY "registry_cr_rls_update_77670e7f14316afe5c99ffee" ON registry_data (cr_target.expected_revision IS NULL AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL) OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) - ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL + ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId') BETWEEN 1 AND 512 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields') BETWEEN 1 AND 1 AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' <@ '["display-name"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -206,7 +206,7 @@ CREATE POLICY "registry_rls_select_27bcfb9b21f0ad364a4fd3f4" ON registry_data."b ) )) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb IS NULL); CREATE POLICY "registry_rls_insert_8d371ca1a68e54fd8e4c4c0b" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_5309e22919f8314952fe990d" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_5309e22919f8314952fe990d" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -214,14 +214,14 @@ CREATE POLICY "registry_cr_action_rls_select_5309e22919f8314952fe990d" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled', 'applied') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_97166faf898c4fcb11d64985" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_97166faf898c4fcb11d64985" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'assisted-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'assisted-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -229,7 +229,7 @@ CREATE POLICY "registry_cr_action_rls_update_97166faf898c4fcb11d64985" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_5685738fe053b5c7cd2c325c" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_5685738fe053b5c7cd2c325c" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -237,14 +237,14 @@ CREATE POLICY "registry_cr_action_rls_select_5685738fe053b5c7cd2c325c" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_e8ee919b7fd7d9879b873a4f" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_e8ee919b7fd7d9879b873a4f" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -252,7 +252,7 @@ CREATE POLICY "registry_cr_action_rls_update_e8ee919b7fd7d9879b873a4f" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_d1c67723b2eed6e32197df11" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_d1c67723b2eed6e32197df11" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -260,14 +260,14 @@ CREATE POLICY "registry_cr_action_rls_select_d1c67723b2eed6e32197df11" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled', 'applied') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_8f4cbe1a563a43f9b92f8f2d" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_8f4cbe1a563a43f9b92f8f2d" ON registry_data."breg_e_person_name_change_request_a6bb1be7a36a0200" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'person-name-change-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'name-change-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.person-name-change-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'name-change-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-name-change') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') diff --git a/products/breg/generated/person-name-change-rhai/generated/schemas/person-name-change-request.schema.json b/products/breg/generated/person-name-change-rhai/generated/schemas/person-name-change-request.schema.json index 3d62fa9dd8..165cf147e8 100644 --- a/products/breg/generated/person-name-change-rhai/generated/schemas/person-name-change-request.schema.json +++ b/products/breg/generated/person-name-change-rhai/generated/schemas/person-name-change-request.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:person-name-change-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"person-name-change-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null}],"application":{"allowedDispositions":["apply","queue"],"mode":"planner","queueReasons":[{"code":"assisted-review","label":"Assisted review requested by synthetic handling."}]},"applyGrants":[{"profileId":"assisted-applier","rowBoundaries":[],"targetEntityId":"person"},{"profileId":"name-change-submitter","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5","effects":[],"planner":{"abi":"registry.change-request-plan/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumFieldMutations":128,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSnapshotBytes":2097152,"maximumSourceBytes":65536,"maximumStringBytes":16384,"maximumTargets":16},"possibleWrites":[{"fields":["display-name"],"operation":"patch","target":{"fromField":"person"}}]},"presenceGrants":[],"requestEntity":"person-name-change-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[],"reviewMode":"none","stages":[],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["person"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:person-name-change-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":80,"minLength":0,"type":"string"},"givenName":{"maxLength":80,"minLength":0,"type":"string"},"handling":{"enum":["routine","assisted"],"type":"string","x-registry-vocabulary":"name-change-handling"},"person":{"format":"uuid","type":"string"}},"required":["person","givenName","familyName","handling"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"person-name-change-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"person-name-change-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null}],"application":{"allowedDispositions":["apply","queue"],"mode":"planner","queueReasons":[{"code":"assisted-review","label":"Assisted review requested by synthetic handling."}]},"applyPermissions":[{"profileId":"assisted-applier","rowBoundaries":[],"targetEntityId":"person"},{"profileId":"name-change-submitter","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35","effects":[],"planner":{"abi":"registry.change-request-plan/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumFieldMutations":128,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSnapshotBytes":2097152,"maximumSourceBytes":65536,"maximumStringBytes":16384,"maximumTargets":16},"possibleWrites":[{"fields":["display-name"],"operation":"patch","target":{"fromField":"person"}}]},"presencePermissions":[],"requestEntity":"person-name-change-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"none","reviewPermissions":[],"stages":[],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["person"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/person-name-change-rhai/generated/schemas/person.schema.json b/products/breg/generated/person-name-change-rhai/generated/schemas/person.schema.json index 96521b3f99..e70188c670 100644 --- a/products/breg/generated/person-name-change-rhai/generated/schemas/person.schema.json +++ b/products/breg/generated/person-name-change-rhai/generated/schemas/person.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:709ef02798050bb5dd132568757aa8b4bd5d86d84a04c6b1d6672c2a855808e5","operations":["patch"],"requestEntity":"person-name-change-request","requestRoute":"person-name-change-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["personCode","displayName"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fca4c246bb7e532847ff671ab65c2669ce0702fb015641aa4b2b8760a576fe35","operations":["patch"],"requestEntity":"person-name-change-request","requestRoute":"person-name-change-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/person-registration-rhai/compiled/actions.json b/products/breg/generated/person-registration-rhai/compiled/actions.json index 76e3997a56..84fbad1367 100644 --- a/products/breg/generated/person-registration-rhai/compiled/actions.json +++ b/products/breg/generated/person-registration-rhai/compiled/actions.json @@ -1 +1 @@ -{"access":[{"actionId":"register-person","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person.invoke"},{"actionId":"register-person-with-registration","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person-with-registration.invoke"},{"actionId":"register-person-with-registration","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person-with-registration.target_conditions"}],"actions":[{"contractFingerprint":"sha256:11fcca263520e53020c3f2ea981ae813f3918d69a32370d224a68d5280af00fc","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"identifier","kind":"set","value":{"kind":"literal","value":null}},{"field":"display-name","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}}],"grants":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"person-registrar","requiredPurposes":["person-registration"],"requiredScopes":["registry:person:register"],"results":["person"],"targets":[{"entityId":"person","rowBoundaries":[]}]}],"handler":{"abi":"registry.action-handler/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSourceBytes":65536,"maximumStringBytes":16384},"refusals":{"blank-name":"At least one name part is required."},"rhaiVersion":"1.25.1","scriptSha256":"sha256:172b73a3b25d93c790a12ac4e034aff99de7697abf1e6011b34856d374d4e40c","sourceModule":null,"writes":[{"fieldTypes":{"display-name":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"fields":["display-name","identifier"],"id":"person","operation":"create","referenceSources":{},"requiredFields":["display-name","identifier"],"targetEntityId":"person","targetFromField":null}]},"id":"register-person","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","required":false}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"resultEffects":["person"],"route":"/v1/actions/register-person","targetUses":[{"conditionRequired":false,"entityId":"person","fields":["display-name","identifier"],"operation":"create","source":{"effect":"person","kind":"effect"}}]},{"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"identifier","kind":"set","value":{"kind":"literal","value":null}},{"field":"display-name","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}},{"dependsOn":[],"id":"register","mutations":[{"field":"last-person","kind":"set","value":{"kind":"literal","value":null}}],"operation":"patch","target":{"binding":{"input":"register","kind":"existing"},"entityId":"register"}},{"dependsOn":[],"id":"registration","mutations":[{"field":"registration-code","kind":"set","value":{"kind":"literal","value":null}},{"field":"person","kind":"set","value":{"kind":"literal","value":null}},{"field":"register","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"registration"}}],"grants":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"person-registrar","requiredPurposes":["person-registration"],"requiredScopes":["registry:person:register"],"results":["person","register","registration"],"targets":[{"entityId":"person","rowBoundaries":[]},{"entityId":"register","rowBoundaries":[]},{"entityId":"registration","rowBoundaries":[]}]}],"handler":{"abi":"registry.action-handler/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSourceBytes":65536,"maximumStringBytes":16384},"refusals":{"blank-name":"At least one name part is required."},"rhaiVersion":"1.25.1","scriptSha256":"sha256:e1139a708b8ea2cb820d457630f30faaea65acfc53db0b55dcfba4f4495efc70","sourceModule":null,"writes":[{"fieldTypes":{"display-name":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"fields":["display-name","identifier"],"id":"person","operation":"create","referenceSources":{},"requiredFields":["display-name","identifier"],"targetEntityId":"person","targetFromField":null},{"fieldTypes":{"last-person":{"onDelete":"restrict","target":"person","type":"reference"}},"fields":["last-person"],"id":"register","operation":"patch","referenceSources":{"last-person":{"createEntities":["person"],"requestFields":[]}},"requiredFields":[],"targetEntityId":"register","targetFromField":"register"},{"fieldTypes":{"person":{"onDelete":"restrict","target":"person","type":"reference"},"register":{"onDelete":"restrict","target":"register","type":"reference"},"registration-code":{"maxLength":64,"minLength":0,"type":"string"}},"fields":["person","register","registration-code"],"id":"registration","operation":"create","referenceSources":{"person":{"createEntities":["person"],"requestFields":[]},"register":{"createEntities":[],"requestFields":["register"]}},"requiredFields":["person","register","registration-code"],"targetEntityId":"registration","targetFromField":null}]},"id":"register-person-with-registration","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"requires":[{"entityId":"register","equals":true,"field":"active","input":"register"}],"resultEffects":["person","register","registration"],"route":"/v1/actions/register-person-with-registration","targetUses":[{"conditionRequired":false,"entityId":"person","fields":["display-name","identifier"],"operation":"create","source":{"effect":"person","kind":"effect"}},{"conditionRequired":true,"entityId":"register","fields":["last-person"],"operation":"patch","source":{"input":"register","kind":"input"}},{"conditionRequired":false,"entityId":"registration","fields":["person","register","registration-code"],"operation":"create","source":{"effect":"registration","kind":"effect"}}]}],"routes":[{"accessProfiles":["person-registrar"],"actionId":"register-person","defaultAccessProfile":"person-registrar","id":"actions.register-person.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-person"},{"accessProfiles":["person-registrar"],"actionId":"register-person-with-registration","defaultAccessProfile":"person-registrar","id":"actions.register-person-with-registration.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-person-with-registration"},{"accessProfiles":["person-registrar"],"actionId":"register-person-with-registration","defaultAccessProfile":"person-registrar","id":"actions.register-person-with-registration.target_conditions","kind":"target_conditions","method":"POST","operation":"invoke","path":"/v1/actions/register-person-with-registration/target-conditions"}]} \ No newline at end of file +{"access":[{"actionId":"register-person","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person.invoke"},{"actionId":"register-person-with-registration","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person-with-registration.invoke"},{"actionId":"register-person-with-registration","defaultProfileId":"person-registrar","operation":"invoke","profileIds":["person-registrar"],"routeId":"actions.register-person-with-registration.target_conditions"}],"actions":[{"contractFingerprint":"sha256:e52b2a39873370c16e78743abbcf47c0ff58971a47f1026ea369a98b5e0a460c","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"identifier","kind":"set","value":{"kind":"literal","value":null}},{"field":"display-name","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}}],"handler":{"abi":"registry.action-handler/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSourceBytes":65536,"maximumStringBytes":16384},"refusals":{"blank-name":"At least one name part is required."},"rhaiVersion":"1.25.1","scriptSha256":"sha256:172b73a3b25d93c790a12ac4e034aff99de7697abf1e6011b34856d374d4e40c","sourceModule":null,"writes":[{"fieldTypes":{"display-name":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"fields":["display-name","identifier"],"id":"person","operation":"create","referenceSources":{},"requiredFields":["display-name","identifier"],"targetEntityId":"person","targetFromField":null}]},"id":"register-person","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","required":false}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"permissions":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"person-registrar","requiredPurposes":["person-registration"],"requiredScopes":["registry:person:register"],"results":["person"],"targets":[{"entityId":"person","rowBoundaries":[]}]}],"resultEffects":["person"],"route":"/v1/actions/register-person","targetUses":[{"conditionRequired":false,"entityId":"person","fields":["display-name","identifier"],"operation":"create","source":{"effect":"person","kind":"effect"}}]},{"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645","effects":[{"dependsOn":[],"id":"person","mutations":[{"field":"identifier","kind":"set","value":{"kind":"literal","value":null}},{"field":"display-name","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"person"}},{"dependsOn":[],"id":"register","mutations":[{"field":"last-person","kind":"set","value":{"kind":"literal","value":null}}],"operation":"patch","target":{"binding":{"input":"register","kind":"existing"},"entityId":"register"}},{"dependsOn":[],"id":"registration","mutations":[{"field":"registration-code","kind":"set","value":{"kind":"literal","value":null}},{"field":"person","kind":"set","value":{"kind":"literal","value":null}},{"field":"register","kind":"set","value":{"kind":"literal","value":null}}],"operation":"create","target":{"binding":{"kind":"create"},"entityId":"registration"}}],"handler":{"abi":"registry.action-handler/v1","kind":"rhai","limits":{"maximumArrayItems":256,"maximumCallDepth":32,"maximumExpressionDepth":64,"maximumMapEntries":256,"maximumModules":0,"maximumOperations":100000,"maximumSourceBytes":65536,"maximumStringBytes":16384},"refusals":{"blank-name":"At least one name part is required."},"rhaiVersion":"1.25.1","scriptSha256":"sha256:e1139a708b8ea2cb820d457630f30faaea65acfc53db0b55dcfba4f4495efc70","sourceModule":null,"writes":[{"fieldTypes":{"display-name":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"fields":["display-name","identifier"],"id":"person","operation":"create","referenceSources":{},"requiredFields":["display-name","identifier"],"targetEntityId":"person","targetFromField":null},{"fieldTypes":{"last-person":{"onDelete":"restrict","target":"person","type":"reference"}},"fields":["last-person"],"id":"register","operation":"patch","referenceSources":{"last-person":{"createEntities":["person"],"requestFields":[]}},"requiredFields":[],"targetEntityId":"register","targetFromField":"register"},{"fieldTypes":{"person":{"onDelete":"restrict","target":"person","type":"reference"},"register":{"onDelete":"restrict","target":"register","type":"reference"},"registration-code":{"maxLength":64,"minLength":0,"type":"string"}},"fields":["person","register","registration-code"],"id":"registration","operation":"create","referenceSources":{"person":{"createEntities":["person"],"requestFields":[]},"register":{"createEntities":[],"requestFields":["register"]}},"requiredFields":["person","register","registration-code"],"targetEntityId":"registration","targetFromField":null}]},"id":"register-person-with-registration","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","required":true}],"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16,"permissions":[{"anonymous":false,"default":true,"operations":["invoke"],"principalClaim":"registry_principal","profileId":"person-registrar","requiredPurposes":["person-registration"],"requiredScopes":["registry:person:register"],"results":["person","register","registration"],"targets":[{"entityId":"person","rowBoundaries":[]},{"entityId":"register","rowBoundaries":[]},{"entityId":"registration","rowBoundaries":[]}]}],"requires":[{"entityId":"register","equals":true,"field":"active","input":"register"}],"resultEffects":["person","register","registration"],"route":"/v1/actions/register-person-with-registration","targetUses":[{"conditionRequired":false,"entityId":"person","fields":["display-name","identifier"],"operation":"create","source":{"effect":"person","kind":"effect"}},{"conditionRequired":true,"entityId":"register","fields":["last-person"],"operation":"patch","source":{"input":"register","kind":"input"}},{"conditionRequired":false,"entityId":"registration","fields":["person","register","registration-code"],"operation":"create","source":{"effect":"registration","kind":"effect"}}]}],"routes":[{"accessProfiles":["person-registrar"],"actionId":"register-person","defaultAccessProfile":"person-registrar","id":"actions.register-person.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-person"},{"accessProfiles":["person-registrar"],"actionId":"register-person-with-registration","defaultAccessProfile":"person-registrar","id":"actions.register-person-with-registration.invoke","kind":"invoke","method":"POST","operation":"invoke","path":"/v1/actions/register-person-with-registration"},{"accessProfiles":["person-registrar"],"actionId":"register-person-with-registration","defaultAccessProfile":"person-registrar","id":"actions.register-person-with-registration.target_conditions","kind":"target_conditions","method":"POST","operation":"invoke","path":"/v1/actions/register-person-with-registration/target-conditions"}]} \ No newline at end of file diff --git a/products/breg/generated/person-registration-rhai/generated/metadata/registry.json b/products/breg/generated/person-registration-rhai/generated/metadata/registry.json index 3c889fd696..5ad3bf7c7a 100644 --- a/products/breg/generated/person-registration-rhai/generated/metadata/registry.json +++ b/products/breg/generated/person-registration-rhai/generated/metadata/registry.json @@ -1 +1 @@ -{"actions":[{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:11fcca263520e53020c3f2ea981ae813f3918d69a32370d224a68d5280af00fc","id":"register-person","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false}],"maximumInputStringBytes":16384,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"person","entity":"person","operation":"create"}],"route":"/v1/actions/register-person","routes":{"invoke":{"inputSchema":"action-register-person-invoke-input","method":"POST","operationId":"actions.register-person.invoke","path":"/v1/actions/register-person","requiresIdempotencyKey":true,"responseSchema":"action-register-person-invoke-response"},"targetConditions":null}},{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}}],"entities":[{"entries":[{"accessProfile":"person-administrator","operation":"create","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.create"},{"accessProfile":"person-administrator","operation":"get","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.get"},{"accessProfile":"person-reader","operation":"get","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.get"},{"accessProfile":"person-administrator","operation":"patch","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.patch"}],"id":"person","route":"people","schemaPath":"/v1/schemas/person"},{"entries":[{"accessProfile":"person-administrator","operation":"create","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.create"},{"accessProfile":"person-administrator","operation":"get","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.get"},{"accessProfile":"person-reader","operation":"get","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.get"},{"accessProfile":"person-administrator","operation":"patch","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.patch"}],"id":"register","route":"registers","schemaPath":"/v1/schemas/register"},{"entries":[{"accessProfile":"person-reader","operation":"get","readableFields":["person","register","registration-code"],"responseEntityId":"registration","routeId":"records.registration.get"}],"id":"registration","route":"registrations","schemaPath":"/v1/schemas/registration"}],"registryId":"person-registration-rhai","version":"0.1.0"} \ No newline at end of file +{"actions":[{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:e52b2a39873370c16e78743abbcf47c0ff58971a47f1026ea369a98b5e0a460c","id":"register-person","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false}],"maximumInputStringBytes":16384,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"person","entity":"person","operation":"create"}],"route":"/v1/actions/register-person","routes":{"invoke":{"inputSchema":"action-register-person-invoke-input","method":"POST","operationId":"actions.register-person.invoke","path":"/v1/actions/register-person","requiresIdempotencyKey":true,"responseSchema":"action-register-person-invoke-response"},"targetConditions":null}},{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}}],"entities":[{"entries":[{"accessProfile":"person-administrator","operation":"create","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.create"},{"accessProfile":"person-administrator","operation":"get","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.get"},{"accessProfile":"person-reader","operation":"get","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.get"},{"accessProfile":"person-administrator","operation":"patch","readableFields":["display-name","identifier"],"responseEntityId":"person","routeId":"records.person.patch"}],"id":"person","route":"people","schemaPath":"/v1/schemas/person"},{"entries":[{"accessProfile":"person-administrator","operation":"create","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.create"},{"accessProfile":"person-administrator","operation":"get","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.get"},{"accessProfile":"person-reader","operation":"get","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.get"},{"accessProfile":"person-administrator","operation":"patch","readableFields":["active","last-person","register-code"],"responseEntityId":"register","routeId":"records.register.patch"}],"id":"register","route":"registers","schemaPath":"/v1/schemas/register"},{"entries":[{"accessProfile":"person-reader","operation":"get","readableFields":["person","register","registration-code"],"responseEntityId":"registration","routeId":"records.registration.get"}],"id":"registration","route":"registrations","schemaPath":"/v1/schemas/registration"}],"registryId":"person-registration-rhai","version":"0.1.0"} \ No newline at end of file diff --git a/products/breg/generated/person-registration-rhai/generated/openapi.json b/products/breg/generated/person-registration-rhai/generated/openapi.json index 4cbb1e2031..d109b9d45a 100644 --- a/products/breg/generated/person-registration-rhai/generated/openapi.json +++ b/products/breg/generated/person-registration-rhai/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-person-invoke-input":{"$id":"urn:breg:action:register-person:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"familyName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"givenName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-person","x-registry-requiredConditionKeys":[]},"action-register-person-invoke-response":{"$id":"urn:breg:action:register-person:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-person"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":[],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-person-with-registration-invoke-input":{"$id":"urn:breg:action:register-person-with-registration:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"familyName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"givenName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"identifier":{"maxLength":13,"minLength":0,"type":"string"},"recordRegistration":{"type":"boolean"},"registerId":{"format":"uuid","type":"string"},"registrationCode":{"maxLength":64,"minLength":0,"type":"string"},"updateRegister":{"type":"boolean"}},"required":["identifier","registerId","registrationCode","recordRegistration","updateRegister"],"type":"object"},"preconditions":{"additionalProperties":false,"properties":{"registerId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["registerId"],"type":"object"}},"required":["input","preconditions"],"type":"object","x-registry-action":"register-person-with-registration","x-registry-requiredConditionKeys":["registerId"]},"action-register-person-with-registration-invoke-response":{"$id":"urn:breg:action:register-person-with-registration:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-person-with-registration"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"register":{"additionalProperties":false,"properties":{"entity":{"const":"register"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"registration":{"additionalProperties":false,"properties":{"entity":{"const":"registration"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":[],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-person-with-registration-target-conditions-input":{"$id":"urn:breg:action:register-person-with-registration:target-conditions-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"registerId":{"format":"uuid","type":"string"}},"required":["registerId"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-person-with-registration","x-registry-requiredConditionKeys":["registerId"]},"action-register-person-with-registration-target-conditions-response":{"$id":"urn:breg:action:register-person-with-registration:target-conditions-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"preconditions":{"additionalProperties":false,"properties":{"registerId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["registerId"],"type":"object"}},"required":["preconditions"],"type":"object","x-registry-action":"register-person-with-registration"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"person-create-input":{"$id":"urn:breg:entity:person:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"register":{"$id":"urn:breg:entity:register","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"active":{"type":"boolean"},"lastPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"registerCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registerCode","active"],"type":"object","x-registry-mutationMode":"mutable"},"register-create-input":{"$id":"urn:breg:entity:register:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"active":{"type":"boolean"},"lastPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"registerCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registerCode","active"],"type":"object","x-registry-mutationMode":"mutable"},"registration":{"$id":"urn:breg:entity:registration","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"person":{"format":"uuid","type":"string"},"register":{"format":"uuid","type":"string"},"registrationCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registrationCode","person","register"],"type":"object","x-registry-mutationMode":"create_only"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"person-registration-rhai","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-person":{"post":{"operationId":"actions.register-person.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"422":{"content":{"application/problem+json":{"examples":{"blank-name":{"value":{"code":"action.refused","detail":"At least one name part is required.","refusalCode":"blank-name","status":422,"title":"Unprocessable Entity","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"500":{"content":{"application/problem+json":{"examples":{"action.handler_failed":{"value":{"code":"action.handler_failed","detail":"The action handler could not produce an accepted result.","status":500,"title":"Internal Server Error","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/handler_failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:11fcca263520e53020c3f2ea981ae813f3918d69a32370d224a68d5280af00fc","id":"register-person","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false}],"maximumInputStringBytes":16384,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"person","entity":"person","operation":"create"}],"route":"/v1/actions/register-person","routes":{"invoke":{"inputSchema":"action-register-person-invoke-input","method":"POST","operationId":"actions.register-person.invoke","path":"/v1/actions/register-person","requiresIdempotencyKey":true,"responseSchema":"action-register-person-invoke-response"},"targetConditions":null}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-person-with-registration":{"post":{"operationId":"actions.register-person-with-registration.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"422":{"content":{"application/problem+json":{"examples":{"blank-name":{"value":{"code":"action.refused","detail":"At least one name part is required.","refusalCode":"blank-name","status":422,"title":"Unprocessable Entity","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"500":{"content":{"application/problem+json":{"examples":{"action.handler_failed":{"value":{"code":"action.handler_failed","detail":"The action handler could not produce an accepted result.","status":500,"title":"Internal Server Error","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/handler_failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-person-with-registration/target-conditions":{"post":{"operationId":"actions.register-person-with-registration.target_conditions","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-target-conditions-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-target-conditions-response"}}},"description":"Action target conditions returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"target_conditions"}},"/v1/records/people":{"post":{"operationId":"records.person.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"person","x-registry-operation":"create","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/people/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator","person-reader"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.person.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"person","x-registry-operation":"patch","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registers":{"post":{"operationId":"records.register.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/register-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"register","x-registry-operation":"create","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registers/{record_id}":{"get":{"operationId":"records.register.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator","person-reader"],"x-registry-entity":"register","x-registry-operation":"get","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.register.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"register","x-registry-operation":"patch","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registrations/{record_id}":{"get":{"operationId":"records.registration.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/registration"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"registration"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/registration"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"registration"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-reader"],"x-registry-entity":"registration","x-registry-operation":"get","x-registry-responseEntity":"registration","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ImmediateActionPrecondition":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"},"ImmediateActionResultReference":{"additionalProperties":false,"properties":{"entity":{"type":"string"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"action-register-person-invoke-input":{"$id":"urn:breg:action:register-person:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"familyName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"givenName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-person","x-registry-requiredConditionKeys":[]},"action-register-person-invoke-response":{"$id":"urn:breg:action:register-person:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-person"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":[],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-person-with-registration-invoke-input":{"$id":"urn:breg:action:register-person-with-registration:invoke-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"familyName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"givenName":{"anyOf":[{"maxLength":80,"minLength":0,"type":"string"},{"type":"null"}]},"identifier":{"maxLength":13,"minLength":0,"type":"string"},"recordRegistration":{"type":"boolean"},"registerId":{"format":"uuid","type":"string"},"registrationCode":{"maxLength":64,"minLength":0,"type":"string"},"updateRegister":{"type":"boolean"}},"required":["identifier","registerId","registrationCode","recordRegistration","updateRegister"],"type":"object"},"preconditions":{"additionalProperties":false,"properties":{"registerId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["registerId"],"type":"object"}},"required":["input","preconditions"],"type":"object","x-registry-action":"register-person-with-registration","x-registry-requiredConditionKeys":["registerId"]},"action-register-person-with-registration-invoke-response":{"$id":"urn:breg:action:register-person-with-registration:invoke-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"action":{"const":"register-person-with-registration"},"applicationId":{"format":"uuid","type":"string"},"results":{"additionalProperties":false,"properties":{"person":{"additionalProperties":false,"properties":{"entity":{"const":"person"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"register":{"additionalProperties":false,"properties":{"entity":{"const":"register"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"},"registration":{"additionalProperties":false,"properties":{"entity":{"const":"registration"},"recordId":{"format":"uuid","type":"string"},"revision":{"format":"int64","minimum":1,"type":"integer"}},"required":["entity","recordId","revision"],"type":"object"}},"required":[],"type":"object"}},"required":["action","applicationId","results"],"type":"object"},"action-register-person-with-registration-target-conditions-input":{"$id":"urn:breg:action:register-person-with-registration:target-conditions-input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"input":{"additionalProperties":false,"properties":{"registerId":{"format":"uuid","type":"string"}},"required":["registerId"],"type":"object"}},"required":["input"],"type":"object","x-registry-action":"register-person-with-registration","x-registry-requiredConditionKeys":["registerId"]},"action-register-person-with-registration-target-conditions-response":{"$id":"urn:breg:action:register-person-with-registration:target-conditions-response","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"preconditions":{"additionalProperties":false,"properties":{"registerId":{"additionalProperties":false,"properties":{"ifMatch":{"maxLength":256,"minLength":3,"pattern":"^\\\"[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"required":["ifMatch"],"type":"object"}},"required":["registerId"],"type":"object"}},"required":["preconditions"],"type":"object","x-registry-action":"register-person-with-registration"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"person-create-input":{"$id":"urn:breg:entity:person:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"displayName":{"maxLength":200,"minLength":1,"type":"string"},"identifier":{"maxLength":13,"minLength":0,"type":"string"}},"required":["identifier","displayName"],"type":"object","x-registry-mutationMode":"mutable"},"register":{"$id":"urn:breg:entity:register","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"active":{"type":"boolean"},"lastPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"registerCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registerCode","active"],"type":"object","x-registry-mutationMode":"mutable"},"register-create-input":{"$id":"urn:breg:entity:register:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"active":{"type":"boolean"},"lastPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"registerCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registerCode","active"],"type":"object","x-registry-mutationMode":"mutable"},"registration":{"$id":"urn:breg:entity:registration","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"person":{"format":"uuid","type":"string"},"register":{"format":"uuid","type":"string"},"registrationCode":{"maxLength":64,"minLength":0,"type":"string"}},"required":["registrationCode","person","register"],"type":"object","x-registry-mutationMode":"create_only"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"person-registration-rhai","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/actions/register-person":{"post":{"operationId":"actions.register-person.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"422":{"content":{"application/problem+json":{"examples":{"blank-name":{"value":{"code":"action.refused","detail":"At least one name part is required.","refusalCode":"blank-name","status":422,"title":"Unprocessable Entity","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"500":{"content":{"application/problem+json":{"examples":{"action.handler_failed":{"value":{"code":"action.handler_failed","detail":"The action handler could not produce an accepted result.","status":500,"title":"Internal Server Error","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/handler_failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":null,"contractFingerprint":"sha256:e52b2a39873370c16e78743abbcf47c0ff58971a47f1026ea369a98b5e0a460c","id":"register-person","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false}],"maximumInputStringBytes":16384,"referenceInputs":[],"requiredConditionKeys":[],"resultEffects":[{"effect":"person","entity":"person","operation":"create"}],"route":"/v1/actions/register-person","routes":{"invoke":{"inputSchema":"action-register-person-invoke-input","method":"POST","operationId":"actions.register-person.invoke","path":"/v1/actions/register-person","requiresIdempotencyKey":true,"responseSchema":"action-register-person-invoke-response"},"targetConditions":null}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-person-with-registration":{"post":{"operationId":"actions.register-person-with-registration.invoke","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to the action route, selected profile, package revision, normalized action input, preconditions, and granted result contract.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-invoke-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-invoke-response"}}},"description":"Immediate action committed","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"422":{"content":{"application/problem+json":{"examples":{"blank-name":{"value":{"code":"action.refused","detail":"At least one name part is required.","refusalCode":"blank-name","status":422,"title":"Unprocessable Entity","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"500":{"content":{"application/problem+json":{"examples":{"action.handler_failed":{"value":{"code":"action.handler_failed","detail":"The action handler could not produce an accepted result.","status":500,"title":"Internal Server Error","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/action/handler_failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"invoke"}},"/v1/actions/register-person-with-registration/target-conditions":{"post":{"operationId":"actions.register-person-with-registration.target_conditions","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-target-conditions-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/action-register-person-with-registration-target-conditions-response"}}},"description":"Action target conditions returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-registrar"],"x-registry-action":{"access":{"accessProfiles":["person-registrar"]},"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"conditionRoute":"/v1/actions/register-person-with-registration/target-conditions","contractFingerprint":"sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645","id":"register-person-with-registration","inputMode":"handler","inputs":[{"apiName":"identifier","classification":"internal","fieldType":{"maxLength":13,"minLength":0,"type":"string"},"id":"identifier","nullable":false,"required":true},{"apiName":"givenName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"given-name","nullable":true,"required":false},{"apiName":"familyName","classification":"internal","fieldType":{"maxLength":80,"minLength":0,"type":"string"},"id":"family-name","nullable":true,"required":false},{"apiName":"registerId","classification":"internal","fieldType":{"onDelete":"restrict","target":"register","type":"reference"},"id":"register","nullable":false,"required":true},{"apiName":"registrationCode","classification":"internal","fieldType":{"maxLength":64,"minLength":0,"type":"string"},"id":"registration-code","nullable":false,"required":true},{"apiName":"recordRegistration","classification":"internal","fieldType":{"type":"boolean"},"id":"record-registration","nullable":false,"required":true},{"apiName":"updateRegister","classification":"internal","fieldType":{"type":"boolean"},"id":"update-register","nullable":false,"required":true}],"maximumInputStringBytes":16384,"referenceInputs":[{"apiName":"registerId","input":"register","targetEntity":"register"}],"requiredConditionKeys":["registerId"],"resultEffects":[{"effect":"person","entity":"person","operation":"create"},{"effect":"register","entity":"register","operation":"patch"},{"effect":"registration","entity":"registration","operation":"create"}],"route":"/v1/actions/register-person-with-registration","routes":{"invoke":{"inputSchema":"action-register-person-with-registration-invoke-input","method":"POST","operationId":"actions.register-person-with-registration.invoke","path":"/v1/actions/register-person-with-registration","requiresIdempotencyKey":true,"responseSchema":"action-register-person-with-registration-invoke-response"},"targetConditions":{"inputSchema":"action-register-person-with-registration-target-conditions-input","method":"POST","operationId":"actions.register-person-with-registration.target_conditions","path":"/v1/actions/register-person-with-registration/target-conditions","requiresIdempotencyKey":false,"responseSchema":"action-register-person-with-registration-target-conditions-response"}}},"x-registry-defaultAccessProfile":"person-registrar","x-registry-operation":"invoke","x-registry-routeKind":"target_conditions"}},"/v1/records/people":{"post":{"operationId":"records.person.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/person-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"person","x-registry-operation":"create","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/people/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator","person-reader"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.person.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"person","x-registry-operation":"patch","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registers":{"post":{"operationId":"records.register.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/register-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"register","x-registry-operation":"create","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registers/{record_id}":{"get":{"operationId":"records.register.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator","person-reader"],"x-registry-entity":"register","x-registry-operation":"get","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.register.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"register"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-administrator"],"x-registry-entity":"register","x-registry-operation":"patch","x-registry-responseEntity":"register","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/registrations/{record_id}":{"get":{"operationId":"records.registration.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/registration"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"registration"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/registration"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"person-registration-rhai"},"entityTypeIdentifier":{"const":"registration"},"registryIdentifier":{"const":"person-registration-rhai"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["person-reader"],"x-registry-entity":"registration","x-registry-operation":"get","x-registry-responseEntity":"registration","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/person-registration-rhai/generated/postgres/schema.sql b/products/breg/generated/person-registration-rhai/generated/postgres/schema.sql index f5672eaf30..921c4b8fb4 100644 --- a/products/breg/generated/person-registration-rhai/generated/postgres/schema.sql +++ b/products/breg/generated/person-registration-rhai/generated/postgres/schema.sql @@ -26,24 +26,24 @@ CREATE POLICY "registry_rls_select_bfe25be5cea23d883b6e9e3b" ON registry_data."b CREATE POLICY "registry_rls_insert_6a28d51236a9333de375fa09" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_2cd758e79b37d75e9e554b9d" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_1f7fa7a279c8d8edbc9f5656" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-reader' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-registration-audit') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_94d8781e5668f8a8cc0b6fc4" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:11fcca263520e53020c3f2ea981ae813f3918d69a32370d224a68d5280af00fc' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_a2bded2fceb2db5393a4f4c6" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:11fcca263520e53020c3f2ea981ae813f3918d69a32370d224a68d5280af00fc' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); -CREATE POLICY "registry_action_rls_select_25ed6a1229ed5a33d50848e7" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_6362e8ad68295c13fcbbbf42" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_94d8781e5668f8a8cc0b6fc4" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:e52b2a39873370c16e78743abbcf47c0ff58971a47f1026ea369a98b5e0a460c' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_a2bded2fceb2db5393a4f4c6" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:e52b2a39873370c16e78743abbcf47c0ff58971a47f1026ea369a98b5e0a460c' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_25ed6a1229ed5a33d50848e7" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_6362e8ad68295c13fcbbbf42" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'person' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('person') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('person', 'identifier'), ('person', 'display-name')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); ALTER TABLE registry_data."breg_e_register_c51c9baa96d83f0f" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_register_c51c9baa96d83f0f" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_eacf63352165b9edde226688" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_0b46bab245b87ee4a9f0fee5" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_update_b108a8bc86e0204d2a189552" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR UPDATE USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active') WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-administrator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-maintenance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_c598958dca321a7935408c95" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-reader' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-registration-audit') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_ca4adb5426024d35271af7b2" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_update_e48704cbc2a4037e66349f75" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' AND record_revision = (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); -CREATE POLICY "registry_action_rls_lock_update_a67f35df0f59dbb9e742b6c7" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'true'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') WITH CHECK (false); +CREATE POLICY "registry_action_rls_select_ca4adb5426024d35271af7b2" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_update_e48704cbc2a4037e66349f75" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' AND record_revision = (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_lock_update_a67f35df0f59dbb9e742b6c7" ON registry_data."breg_e_register_c51c9baa96d83f0f" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'register' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('register') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('register', 'last-person')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'register' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'true'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') WITH CHECK (false); ALTER TABLE registry_data."breg_e_registration_eddba60f58e77eb0" ENABLE ROW LEVEL SECURITY; ALTER TABLE registry_data."breg_e_registration_eddba60f58e77eb0" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_6d77852b227c779d808659b9" ON registry_data."breg_e_registration_eddba60f58e77eb0" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'person-reader' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('person-registration-audit') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); -CREATE POLICY "registry_action_rls_select_d9f6176db648f47cbf393781" ON registry_data."breg_e_registration_eddba60f58e77eb0" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'registration' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('registration') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('registration', 'registration-code'), ('registration', 'person'), ('registration', 'register')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_action_rls_insert_321cfe14e275e784a1655656" ON registry_data."breg_e_registration_eddba60f58e77eb0" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:2b3898ab193c026f51953720c56a84ce9baa36fd01ed17eb1c3f0c0a1935e4f1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'registration' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('registration') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('registration', 'registration-code'), ('registration', 'person'), ('registration', 'register')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); +CREATE POLICY "registry_action_rls_select_d9f6176db648f47cbf393781" ON registry_data."breg_e_registration_eddba60f58e77eb0" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'registration' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('registration') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('registration', 'registration-code'), ('registration', 'person'), ('registration', 'register')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); +CREATE POLICY "registry_action_rls_insert_321cfe14e275e784a1655656" ON registry_data."breg_e_registration_eddba60f58e77eb0" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'actionId' = 'register-person-with-registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c8f37d6b06a23e03bc13bb8abf09c03e1a999386a46ee0233fbe22fa2b4b5645' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'person-registrar' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '') AND ((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') BETWEEN 1 AND 128 AND jsonb_array_length(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') = ( SELECT count(DISTINCT context_effects.effect_id) FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) ) AND jsonb_typeof(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields') = 'array' AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') ? 'registration' AND NOT EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id NOT IN ('registration') ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'fields' = ( SELECT COALESCE(jsonb_agg(selected_fields.field ORDER BY selected_fields.field), '[]'::jsonb) FROM ( SELECT DISTINCT effect_fields.field FROM (VALUES ('registration', 'registration-code'), ('registration', 'person'), ('registration', 'register')) AS effect_fields(effect_id, field) WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text(NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'effectIds') AS context_effects(effect_id) WHERE context_effects.effect_id = effect_fields.effect_id ) ) AS selected_fields ) AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetEntityId' = 'registration' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND jsonb_typeof((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb -> 'lockOnly' = 'false'::jsonb AND (NULLIF(current_setting('registry.immediate_action_target_context', true), '')::jsonb ->> 'applicationId') ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); CREATE VIEW registry_source."person" WITH (security_invoker=true, security_barrier=true) AS SELECT record_id AS "id", "breg_f_person_identifier_c3a807feeeec5074" AS "identifier", "breg_f_person_display_name_f08a3f8b05183d6e" AS "display_name" diff --git a/products/breg/generated/publicschema-household-change-requests/generated/openapi.json b/products/breg/generated/publicschema-household-change-requests/generated/openapi.json index 62b7ec869a..fae8876fd8 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/openapi.json +++ b/products/breg/generated/publicschema-household-change-requests/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"group-membership":{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"},"household":{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"childUnder5Count":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"elderlyCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"headCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true},"womanHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["patch"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"household-create-input":{"$id":"urn:breg:entity:household:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]},"familyName":{"anyOf":[{"maxLength":120,"minLength":0,"type":"string"},{"type":"null"}]},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"anyOf":[{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},{"type":"null"}]},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"},"register-household-contact-request":{"$id":"urn:breg:entity:register-household-contact-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"register-household-contact-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"group-membership"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"household"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","effects":[{"dependsOn":[],"id":"person","mutations":[{"apiName":"familyName","field":"family-name","kind":"set","value":{"field":"family-name","kind":"from_field"}},{"apiName":"legalName","field":"legal-name","kind":"set","value":{"field":"legal-name","kind":"from_field"}},{"apiName":"personCode","field":"person-code","kind":"set","value":{"field":"person-code","kind":"from_field"}},{"apiName":"personSex","field":"person-sex","kind":"set","value":{"field":"person-sex","kind":"from_field"}},{"apiName":"residencyStatus","field":"residency-status","kind":"set","value":{"field":"residency-status","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"person","kind":"reserved_create"},"entity":"person"}},{"dependsOn":["person"],"id":"effect-3","mutations":[{"apiName":"contactPerson","field":"contact-person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}}],"operation":"patch","target":{"binding":{"fromField":"household","kind":"existing"},"entity":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"apiName":"household","field":"household","kind":"set","value":{"field":"household","kind":"from_field"}},{"apiName":"person","field":"person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}},{"apiName":"relationship","field":"relationship","kind":"set","value":{"field":"relationship","kind":"from_field"}},{"apiName":"validFrom","field":"valid-from","kind":"set","value":{"field":"valid-from","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"membership","kind":"reserved_create"},"entity":"group-membership"}}],"planner":{"kind":"declarative"},"presenceGrants":[{"profileId":"household-operator","requestRowBoundaries":[],"targetEntityId":"household"}],"requestEntity":"register-household-contact-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"household-contact-supervisor","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"group-membership"},{"profileId":"household-contact-supervisor","readableFields":["contact-person"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"household"},{"profileId":"household-contact-supervisor","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"person"},{"profileId":"household-contact-reviewer","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"review","targetEntityId":"group-membership"},{"profileId":"household-contact-reviewer","readableFields":["contact-person"],"rowBoundaries":[],"stage":"review","targetEntityId":"household"},{"profileId":"household-contact-reviewer","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"review","targetEntityId":"person"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["group-membership","household","person"]},"x-registry-mutationMode":"mutable"},"register-household-contact-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"register-household-contact-request-create-input":{"$id":"urn:breg:entity:register-household-contact-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-mutationMode":"mutable"},"register-household-contact-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-revise_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"rebase":{"type":"boolean"}},"required":["rebase"],"type":"object"},"register-household-contact-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"publicschema-household-change-requests","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.group-membership.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/household-contact-requests":{"get":{"operationId":"records.register-household-contact-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-contact-reviewer":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"household-contact-reviewer","selectableProperties":["familyName","household","legalName","personCode","personSex","reason","relationship","residencyStatus","validFrom"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.register-household-contact-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/register-household-contact-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"create","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/household-contact-requests/{record_id}":{"get":{"operationId":"records.register-household-contact-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-applier","household-contact-reviewer","household-contact-submitter","household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"get","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.register-household-contact-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"patch","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/household-contact-requests/{record_id}/actions/apply":{"post":{"operationId":"records.register-household-contact-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-applier"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/household-contact-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.register-household-contact-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/household-contact-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/revise":{"post":{"operationId":"records.register-household-contact-request.request.revise","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-revise_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"revise_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-revise_request-input","method":"post","operation":"revise_request","path":"/v1/records/household-contact-requests/{record_id}/actions/revise","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/approve":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/reject":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/request-revision":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"register-household-contact-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"register-household-contact-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/submit":{"post":{"operationId":"records.register-household-contact-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/household-contact-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","contactPerson","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null}},"x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.household.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"selector":{"maxLength":128,"type":"string"},"values":{"additionalProperties":{"oneOf":[{"maxLength":1024,"type":"string"},{"format":"int64","type":"integer"},{"type":"boolean"}]},"maxProperties":16,"type":"object"}},"required":["selector"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Lookup resolved to one record","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"lookup.unresolved":{"value":{"code":"lookup.unresolved","detail":"The lookup did not resolve exactly one record.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/lookup/unresolved"}},"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","contactPerson","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":["householdCode"],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null},"household-viewer":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"household-viewer","selectableProperties":["administrativeArea","householdCode","householdName","householdType","localHouseholdNumber"],"selectorProperties":["householdCode"],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","preferredLanguage","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.person.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"group-membership":{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"},"household":{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"childUnder5Count":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"elderlyCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"headCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true},"womanHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["patch"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"household-create-input":{"$id":"urn:breg:entity:household:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-mutationMode":"mutable"},"person":{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]},"familyName":{"anyOf":[{"maxLength":120,"minLength":0,"type":"string"},{"type":"null"}]},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"anyOf":[{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},{"type":"null"}]},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"},"register-household-contact-request":{"$id":"urn:breg:entity:register-household-contact-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"register-household-contact-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"group-membership"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"household"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","effects":[{"dependsOn":[],"id":"person","mutations":[{"apiName":"familyName","field":"family-name","kind":"set","value":{"field":"family-name","kind":"from_field"}},{"apiName":"legalName","field":"legal-name","kind":"set","value":{"field":"legal-name","kind":"from_field"}},{"apiName":"personCode","field":"person-code","kind":"set","value":{"field":"person-code","kind":"from_field"}},{"apiName":"personSex","field":"person-sex","kind":"set","value":{"field":"person-sex","kind":"from_field"}},{"apiName":"residencyStatus","field":"residency-status","kind":"set","value":{"field":"residency-status","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"person","kind":"reserved_create"},"entity":"person"}},{"dependsOn":["person"],"id":"effect-3","mutations":[{"apiName":"contactPerson","field":"contact-person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}}],"operation":"patch","target":{"binding":{"fromField":"household","kind":"existing"},"entity":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"apiName":"household","field":"household","kind":"set","value":{"field":"household","kind":"from_field"}},{"apiName":"person","field":"person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}},{"apiName":"relationship","field":"relationship","kind":"set","value":{"field":"relationship","kind":"from_field"}},{"apiName":"validFrom","field":"valid-from","kind":"set","value":{"field":"valid-from","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"membership","kind":"reserved_create"},"entity":"group-membership"}}],"planner":{"kind":"declarative"},"presencePermissions":[{"profileId":"household-operator","requestRowBoundaries":[],"targetEntityId":"household"}],"requestEntity":"register-household-contact-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"household-contact-supervisor","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"group-membership"},{"profileId":"household-contact-supervisor","readableFields":["contact-person"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"household"},{"profileId":"household-contact-supervisor","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"person"},{"profileId":"household-contact-reviewer","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"review","targetEntityId":"group-membership"},{"profileId":"household-contact-reviewer","readableFields":["contact-person"],"rowBoundaries":[],"stage":"review","targetEntityId":"household"},{"profileId":"household-contact-reviewer","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"review","targetEntityId":"person"}],"stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["group-membership","household","person"]},"x-registry-mutationMode":"mutable"},"register-household-contact-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"register-household-contact-request-create-input":{"$id":"urn:breg:entity:register-household-contact-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-mutationMode":"mutable"},"register-household-contact-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"register-household-contact-request-revise_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"rebase":{"type":"boolean"}},"required":["rebase"],"type":"object"},"register-household-contact-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"publicschema-household-change-requests","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/group-memberships":{"get":{"operationId":"records.group-membership.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships/{record_id}":{"get":{"operationId":"records.group-membership.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"get","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.group-membership.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"patch","x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/group-memberships:as-of":{"get":{"operationId":"records.group-membership.as-of","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Strict UTC RFC3339 instant for the as-of temporal query.","explode":false,"in":"query","name":"asOf","required":true,"schema":{"format":"date-time","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"as_of","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"as_of","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/group-memberships:current":{"get":{"operationId":"records.group-membership.current","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/group-membership"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"group-membership"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"group-membership","x-registry-operation":"list","x-registry-queryKind":"current","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null"],"property":"household"},{"operators":["equals","in","is_null","is_not_null"],"property":"person"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"relationship"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"validFrom"}],"kind":"current","maxPageSize":100,"profile":"household-operator","selectableProperties":["household","person","relationship","validFrom","validTo"],"selectorProperties":[],"sortableProperties":[],"temporal":{"endProperty":"validTo","semantics":"start_inclusive_end_exclusive","startProperty":"validFrom","valueKind":"date"}}},"x-registry-responseEntity":"group-membership","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/household-contact-requests":{"get":{"operationId":"records.register-household-contact-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-contact-reviewer":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"household-contact-reviewer","selectableProperties":["familyName","household","legalName","personCode","personSex","reason","relationship","residencyStatus","validFrom"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.register-household-contact-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/register-household-contact-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"create","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/household-contact-requests/{record_id}":{"get":{"operationId":"records.register-household-contact-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-applier","household-contact-reviewer","household-contact-submitter","household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"get","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.register-household-contact-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/register-household-contact-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"register-household-contact-request"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"patch","x-registry-responseEntity":"register-household-contact-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/household-contact-requests/{record_id}/actions/apply":{"post":{"operationId":"records.register-household-contact-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-applier"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/household-contact-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.register-household-contact-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/household-contact-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/revise":{"post":{"operationId":"records.register-household-contact-request.request.revise","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-revise_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"revise_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-revise_request-input","method":"post","operation":"revise_request","path":"/v1/records/household-contact-requests/{record_id}/actions/revise","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/approve":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/reject":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/request-revision":{"post":{"operationId":"records.register-household-contact-request.request.stages.final-approval.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-supervisor"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"register-household-contact-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/final-approval/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"final-approval","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.register-household-contact-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-reviewer"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"register-household-contact-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/household-contact-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/household-contact-requests/{record_id}/actions/submit":{"post":{"operationId":"records.register-household-contact-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/register-household-contact-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-contact-submitter"],"x-registry-entity":"register-household-contact-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"register-household-contact-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/household-contact-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"register-household-contact-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["group-membership","household","person"]},"x-registry-responseEntity":"register-household-contact-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/households":{"get":{"operationId":"records.household.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","contactPerson","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null}},"x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.household.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/household-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"create","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}":{"get":{"operationId":"records.household.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"get","x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/households/{record_id}/people":{"get":{"operationId":"records.household.path.people","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"household","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":true,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/households:lookup":{"post":{"operationId":"records.household.lookup","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"selector":{"maxLength":128,"type":"string"},"values":{"additionalProperties":{"oneOf":[{"maxLength":1024,"type":"string"},{"format":"int64","type":"integer"},{"type":"boolean"}]},"maxProperties":16,"type":"object"}},"required":["selector"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/household"},"recordIdentifier":{"format":"uuid","type":"string"},"requestPresence":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"pending":{"type":"boolean"},"requestType":{"type":"string"}},"required":["requestType","pending"],"type":"object"},"maxItems":64,"type":"array"}},"required":["requests"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"household"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Lookup resolved to one record","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"lookup.unresolved":{"value":{"code":"lookup.unresolved","detail":"The lookup did not resolve exactly one record.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/lookup/unresolved"}},"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator","household-viewer"],"x-registry-entity":"household","x-registry-operation":"lookup","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"administrativeArea"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"childUnder5Count"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"elderlyCount"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"headCount"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"householdType"},{"operators":["equals","in","range","is_null","is_not_null"],"property":"localHouseholdNumber"},{"operators":["equals","in","is_null","is_not_null"],"property":"singleHeaded"},{"operators":["equals","in","is_null","is_not_null"],"property":"womanHeaded"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["administrativeArea","childCount","childUnder5Count","contactPerson","elderlyCount","headCount","householdCode","householdName","householdType","localHouseholdNumber","singleHeaded","womanHeaded"],"selectorProperties":["householdCode"],"sortableProperties":[{"directions":["asc"],"property":"childCount"},{"directions":["asc"],"property":"householdCode"},{"directions":["asc"],"property":"localHouseholdNumber"}],"temporal":null},"household-viewer":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"household-viewer","selectableProperties":["administrativeArea","householdCode","householdName","householdType","localHouseholdNumber"],"selectorProperties":["householdCode"],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"household","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/persons":{"get":{"operationId":"records.person.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"household-operator":{"allowCount":false,"filterableProperties":[{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personCode"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"personSex"},{"operators":["equals","in","is_null","is_not_null","prefix","contains"],"property":"residencyStatus"}],"kind":"list","maxPageSize":100,"profile":"household-operator","selectableProperties":["dateOfBirth","familyName","legalName","personCode","personSex","preferredLanguage","residencyStatus"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"personCode"}],"temporal":null}},"x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"}},"/v1/records/persons/{record_id}":{"get":{"operationId":"records.person.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"get","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.person.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/person"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"household-registry"},"entityTypeIdentifier":{"const":"person"},"registryIdentifier":{"const":"publicschema-household-change-requests"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["household-operator"],"x-registry-entity":"person","x-registry-operation":"patch","x-registry-responseEntity":"person","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/publicschema-household-change-requests/generated/postgres/schema.sql b/products/breg/generated/publicschema-household-change-requests/generated/postgres/schema.sql index 52a7592488..fc179629a9 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/postgres/schema.sql +++ b/products/breg/generated/publicschema-household-change-requests/generated/postgres/schema.sql @@ -36,7 +36,7 @@ CREATE POLICY "registry_path_rls_select_e540f4a2a42bba5df8bbcb27" ON registry_da AND path_source.record_lifecycle = 'active' AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) )); -CREATE POLICY "registry_cr_rls_select_de403aa5c8ee74ded62c3062" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_de403aa5c8ee74ded62c3062" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -45,7 +45,7 @@ CREATE POLICY "registry_cr_rls_select_de403aa5c8ee74ded62c3062" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_4db72b22f225f6c4ee2be115" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_4db72b22f225f6c4ee2be115" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -71,7 +71,7 @@ CREATE POLICY "registry_cr_rls_select_4db72b22f225f6c4ee2be115" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_b08a94dc279bc94553973127" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_b08a94dc279bc94553973127" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -97,7 +97,7 @@ CREATE POLICY "registry_cr_rls_select_b08a94dc279bc94553973127" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_9efcec5c1d52eaa879d5babd" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_9efcec5c1d52eaa879d5babd" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -123,7 +123,7 @@ CREATE POLICY "registry_cr_rls_select_9efcec5c1d52eaa879d5babd" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_insert_a59209c066e8b541a14e992a" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_insert_a59209c066e8b541a14e992a" ON registry_data."breg_e_group_membership_a576b1bf8befd33b" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'group-membership' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["household","person","relationship","valid-from"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -155,7 +155,7 @@ CREATE POLICY "registry_rls_select_d34f6f0be055972f57e8c1cb" ON registry_data."b CREATE POLICY "registry_rls_insert_0114d32c4eff36a73fdc0507" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); CREATE POLICY "registry_rls_select_af1a729741d57686fe5a2586" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-viewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-view') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 1 AND jsonb_typeof((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0)) = 'object' AND ((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) - 'field' - 'operator' - 'values') = '{}'::jsonb AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'field' = 'id' AND (NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) ->> 'operator' = 'equals' AND jsonb_typeof(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 'array' AND jsonb_array_length(((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values')) = 1 AND "record_id" = (((NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb -> 0) -> 'values') ->> 0)::uuid) AND (record_lifecycle = 'active')); CREATE POLICY "registry_path_rls_select_e8831d0183cd81e5dff8ba1d" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND NULLIF(current_setting('registry.read_path_id', true), '') = 'people' AND record_id = NULLIF(current_setting('registry.read_path_root_id', true), '')::uuid AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_499b165a2bee7580be48c97a" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_499b165a2bee7580be48c97a" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -164,7 +164,7 @@ CREATE POLICY "registry_cr_rls_select_499b165a2bee7580be48c97a" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_71c9c2b4a9f85cefbb9d6842" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_71c9c2b4a9f85cefbb9d6842" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -190,7 +190,7 @@ CREATE POLICY "registry_cr_rls_select_71c9c2b4a9f85cefbb9d6842" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_f1ac6301418174bb5a3a08e6" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_f1ac6301418174bb5a3a08e6" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -216,7 +216,7 @@ CREATE POLICY "registry_cr_rls_select_f1ac6301418174bb5a3a08e6" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_cd02c35af1a2c117ac359415" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_cd02c35af1a2c117ac359415" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -242,7 +242,7 @@ CREATE POLICY "registry_cr_rls_select_cd02c35af1a2c117ac359415" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_update_fc713f8318b6172808147c41" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_update_fc713f8318b6172808147c41" ON registry_data."breg_e_household_99c2dbe1ce1a9259" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -267,7 +267,7 @@ CREATE POLICY "registry_cr_rls_update_fc713f8318b6172808147c41" ON registry_data (cr_target.expected_revision IS NULL AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL) OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) - ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL + ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'effect-3' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["contact-person"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -309,7 +309,7 @@ CREATE POLICY "registry_path_rls_select_641a438e91f8ad83a8f0aef5" ON registry_da AND path_source.record_lifecycle = 'active' AND (NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) )); -CREATE POLICY "registry_cr_rls_select_7a131b2cd5ca40787f93d228" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_7a131b2cd5ca40787f93d228" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -318,7 +318,7 @@ CREATE POLICY "registry_cr_rls_select_7a131b2cd5ca40787f93d228" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_de4031a67c01152ebac63194" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_de4031a67c01152ebac63194" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"final-approval"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -344,7 +344,7 @@ CREATE POLICY "registry_cr_rls_select_de4031a67c01152ebac63194" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_b525341b2aed5b6abdca2d43" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_b525341b2aed5b6abdca2d43" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -370,7 +370,7 @@ CREATE POLICY "registry_cr_rls_select_b525341b2aed5b6abdca2d43" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_e1f2a8395a47dd9536299214" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_select_e1f2a8395a47dd9536299214" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -396,7 +396,7 @@ CREATE POLICY "registry_cr_rls_select_e1f2a8395a47dd9536299214" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_insert_bfd98195edc0449ee8ad4e6d" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL +CREATE POLICY "registry_cr_rls_insert_bfd98195edc0449ee8ad4e6d" ON registry_data."breg_e_person_6ebfb4811e7ddd30" FOR INSERT WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'person' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'create' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["family-name","legal-name","person-code","person-sex","residency-status"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -470,7 +470,7 @@ CREATE POLICY "registry_rls_select_7e40c6067648bd794d8d980b" ON registry_data."b AND cr_state.detail_erased_at IS NOT NULL ) )) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb IS NULL); -CREATE POLICY "registry_cr_action_rls_select_66d548ea157296bcc0347868" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_66d548ea157296bcc0347868" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -478,14 +478,14 @@ CREATE POLICY "registry_cr_action_rls_select_66d548ea157296bcc0347868" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled', 'applied') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_654b4c26d8261ac6aa5f1ae3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_654b4c26d8261ac6aa5f1ae3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-apply') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -493,7 +493,7 @@ CREATE POLICY "registry_cr_action_rls_update_654b4c26d8261ac6aa5f1ae3" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_618637710d8e3c027d8902d0" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_618637710d8e3c027d8902d0" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -501,14 +501,14 @@ CREATE POLICY "registry_cr_action_rls_select_618637710d8e3c027d8902d0" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_6ccbc03d100049c48a148c19" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_6ccbc03d100049c48a148c19" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -516,7 +516,7 @@ CREATE POLICY "registry_cr_action_rls_update_6ccbc03d100049c48a148c19" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_32d5c69def21cc4b56c2beec" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_32d5c69def21cc4b56c2beec" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -524,14 +524,14 @@ CREATE POLICY "registry_cr_action_rls_select_32d5c69def21cc4b56c2beec" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_83697d02527f31bc799cc6ac" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_83697d02527f31bc799cc6ac" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -539,7 +539,7 @@ CREATE POLICY "registry_cr_action_rls_update_83697d02527f31bc799cc6ac" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_cda1bb8b9178cbdce1354b9f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_cda1bb8b9178cbdce1354b9f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -547,14 +547,14 @@ CREATE POLICY "registry_cr_action_rls_select_cda1bb8b9178cbdce1354b9f" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_745ab925233edadc8f1cdc16" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_745ab925233edadc8f1cdc16" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -562,7 +562,7 @@ CREATE POLICY "registry_cr_action_rls_update_745ab925233edadc8f1cdc16" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_60f6b285387f1374ecf2ec98" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_60f6b285387f1374ecf2ec98" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -570,14 +570,14 @@ CREATE POLICY "registry_cr_action_rls_select_60f6b285387f1374ecf2ec98" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_3981d89ff55967534eb8a7d9" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_3981d89ff55967534eb8a7d9" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -585,7 +585,7 @@ CREATE POLICY "registry_cr_action_rls_update_3981d89ff55967534eb8a7d9" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_cc970ed9d11e1c2e4da6834f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_cc970ed9d11e1c2e4da6834f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -593,14 +593,14 @@ CREATE POLICY "registry_cr_action_rls_select_cc970ed9d11e1c2e4da6834f" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_39e657b5ea780e9c20ce6f74" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_39e657b5ea780e9c20ce6f74" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -608,7 +608,7 @@ CREATE POLICY "registry_cr_action_rls_update_39e657b5ea780e9c20ce6f74" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_96c1a1a034c8d7fee7a3d5f3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_96c1a1a034c8d7fee7a3d5f3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -616,14 +616,14 @@ CREATE POLICY "registry_cr_action_rls_select_96c1a1a034c8d7fee7a3d5f3" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_f7856ca816361a74a28104b7" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_f7856ca816361a74a28104b7" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -631,7 +631,7 @@ CREATE POLICY "registry_cr_action_rls_update_f7856ca816361a74a28104b7" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_11656cacaef331b2f2b72cc5" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_11656cacaef331b2f2b72cc5" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -639,14 +639,14 @@ CREATE POLICY "registry_cr_action_rls_select_11656cacaef331b2f2b72cc5" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_9adbff735db178228bc98266" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_9adbff735db178228bc98266" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -654,7 +654,7 @@ CREATE POLICY "registry_cr_action_rls_update_9adbff735db178228bc98266" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_8c6cf272df819a2e3bd3988f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_8c6cf272df819a2e3bd3988f" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -662,14 +662,14 @@ CREATE POLICY "registry_cr_action_rls_select_8c6cf272df819a2e3bd3988f" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_76566801239f5637ad685b09" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_76566801239f5637ad685b09" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted', 'approved', 'needs_changes', 'rejected') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'revise_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.revise' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -677,7 +677,7 @@ CREATE POLICY "registry_cr_action_rls_update_76566801239f5637ad685b09" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted', 'approved', 'needs_changes', 'rejected') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_4ba13ef510c35a09ec60fa20" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_4ba13ef510c35a09ec60fa20" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -685,7 +685,7 @@ CREATE POLICY "registry_cr_action_rls_select_4ba13ef510c35a09ec60fa20" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_71b422860c70a1ed46583057" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_71b422860c70a1ed46583057" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -693,7 +693,7 @@ CREATE POLICY "registry_cr_action_rls_update_71b422860c70a1ed46583057" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-submitter' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-submitter' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-registration') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -702,7 +702,7 @@ CREATE POLICY "registry_cr_action_rls_update_71b422860c70a1ed46583057" ON regist AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_8c783673de19be6b683c48d6" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_8c783673de19be6b683c48d6" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -710,14 +710,14 @@ CREATE POLICY "registry_cr_action_rls_select_8c783673de19be6b683c48d6" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_7d17ac4891587c772616761d" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_7d17ac4891587c772616761d" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -725,7 +725,7 @@ CREATE POLICY "registry_cr_action_rls_update_7d17ac4891587c772616761d" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_977c56d73ab5d4c264e476a7" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_977c56d73ab5d4c264e476a7" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -733,14 +733,14 @@ CREATE POLICY "registry_cr_action_rls_select_977c56d73ab5d4c264e476a7" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_018952f341b2b3d01816c0b4" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_018952f341b2b3d01816c0b4" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -748,7 +748,7 @@ CREATE POLICY "registry_cr_action_rls_update_018952f341b2b3d01816c0b4" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_ee134119a280fb6f6d06cb01" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_ee134119a280fb6f6d06cb01" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -756,14 +756,14 @@ CREATE POLICY "registry_cr_action_rls_select_ee134119a280fb6f6d06cb01" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_af9810b46665433bc0902c52" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_af9810b46665433bc0902c52" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -771,7 +771,7 @@ CREATE POLICY "registry_cr_action_rls_update_af9810b46665433bc0902c52" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_782402d9590e38334833cc4d" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_782402d9590e38334833cc4d" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -779,14 +779,14 @@ CREATE POLICY "registry_cr_action_rls_select_782402d9590e38334833cc4d" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_47033072c85b9473cf686d55" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_47033072c85b9473cf686d55" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -794,7 +794,7 @@ CREATE POLICY "registry_cr_action_rls_update_47033072c85b9473cf686d55" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_9e94c69084473dd49d2c1ab2" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_9e94c69084473dd49d2c1ab2" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -802,14 +802,14 @@ CREATE POLICY "registry_cr_action_rls_select_9e94c69084473dd49d2c1ab2" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_68ebfa9838c6bdea62df7901" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_68ebfa9838c6bdea62df7901" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -817,7 +817,7 @@ CREATE POLICY "registry_cr_action_rls_update_68ebfa9838c6bdea62df7901" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_9aa34aaab3c5d364b9eacbc4" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_9aa34aaab3c5d364b9eacbc4" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -825,14 +825,14 @@ CREATE POLICY "registry_cr_action_rls_select_9aa34aaab3c5d364b9eacbc4" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_fe0f4dd47e6b1d19c3a64ab3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_fe0f4dd47e6b1d19c3a64ab3" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-contact-supervisor' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'final-approval' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.register-household-contact-request.request.stages.final-approval.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-contact-supervisor' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-contact-review') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -840,7 +840,7 @@ CREATE POLICY "registry_cr_action_rls_update_fe0f4dd47e6b1d19c3a64ab3" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_presence_rls_select_f3c259981b5d0c368bf44beb" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-operator' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_presence_rls_select_f3c259981b5d0c368bf44beb" ON registry_data."breg_e_register_household_contact_request_9fac95c7279ab034" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'requestEntityId' = 'register-household-contact-request' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetEntityId' = 'household' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'targetRecordId') IS NOT NULL AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537' AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'household-operator' AND (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'household-operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('household-administration') AND jsonb_typeof((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_presence_context', true), '')::jsonb -> 'requestRowBoundaries')) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state JOIN registry_internal.registry_request_proposals AS cr_proposal diff --git a/products/breg/generated/publicschema-household-change-requests/generated/schemas/group-membership.schema.json b/products/breg/generated/publicschema-household-change-requests/generated/schemas/group-membership.schema.json index 7d49ceadc9..ff56c55c1d 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/schemas/group-membership.schema.json +++ b/products/breg/generated/publicschema-household-change-requests/generated/schemas/group-membership.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:group-membership","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"household":{"format":"uuid","type":"string"},"person":{"format":"uuid","type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"validFrom":{"format":"date","type":"string"},"validTo":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]}},"required":["person","household","relationship","validFrom"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/publicschema-household-change-requests/generated/schemas/household.schema.json b/products/breg/generated/publicschema-household-change-requests/generated/schemas/household.schema.json index b93128b072..1aafba2126 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/schemas/household.schema.json +++ b/products/breg/generated/publicschema-household-change-requests/generated/schemas/household.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"childUnder5Count":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"elderlyCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"headCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true},"womanHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["patch"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:household","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"administrativeArea":{"maxLength":80,"minLength":0,"type":"string"},"childCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"childUnder5Count":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"contactPerson":{"anyOf":[{"format":"uuid","type":"string"},{"type":"null"}]},"elderlyCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"headCount":{"anyOf":[{"format":"int64","type":"integer"},{"type":"null"}],"readOnly":true},"householdCode":{"maxLength":64,"minLength":0,"type":"string"},"householdName":{"maxLength":160,"minLength":0,"type":"string"},"householdType":{"enum":["private","collective","institutional"],"type":"string","x-registry-vocabulary":"household-type"},"localHouseholdNumber":{"format":"int64","type":"integer"},"singleHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true},"womanHeaded":{"anyOf":[{"type":"boolean"},{"type":"null"}],"readOnly":true}},"required":["householdCode","localHouseholdNumber","householdName","administrativeArea","householdType"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["patch"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/publicschema-household-change-requests/generated/schemas/person.schema.json b/products/breg/generated/publicschema-household-change-requests/generated/schemas/person.schema.json index ee59be7df1..7f5a7a1cee 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/schemas/person.schema.json +++ b/products/breg/generated/publicschema-household-change-requests/generated/schemas/person.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]},"familyName":{"anyOf":[{"maxLength":120,"minLength":0,"type":"string"},{"type":"null"}]},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"anyOf":[{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},{"type":"null"}]},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:person","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"dateOfBirth":{"anyOf":[{"format":"date","type":"string"},{"type":"null"}]},"familyName":{"anyOf":[{"maxLength":120,"minLength":0,"type":"string"},{"type":"null"}]},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"preferredLanguage":{"anyOf":[{"enum":["en","es","fr"],"type":"string","x-registry-vocabulary":"preferred-language"},{"type":"null"}]},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"}},"required":["personCode","legalName","personSex","residencyStatus"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","operations":["create"],"requestEntity":"register-household-contact-request","requestRoute":"household-contact-requests"}],"requiredFor":["create"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/publicschema-household-change-requests/generated/schemas/register-household-contact-request.schema.json b/products/breg/generated/publicschema-household-change-requests/generated/schemas/register-household-contact-request.schema.json index f85e0dbbbb..87b1d3e617 100644 --- a/products/breg/generated/publicschema-household-change-requests/generated/schemas/register-household-contact-request.schema.json +++ b/products/breg/generated/publicschema-household-change-requests/generated/schemas/register-household-contact-request.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:register-household-contact-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"register-household-contact-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"group-membership"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"household"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:fb6254afabeb1bbe7cfffff3d87e481bd9b1cd075dee2732f4c4d322a86a450d","effects":[{"dependsOn":[],"id":"person","mutations":[{"apiName":"familyName","field":"family-name","kind":"set","value":{"field":"family-name","kind":"from_field"}},{"apiName":"legalName","field":"legal-name","kind":"set","value":{"field":"legal-name","kind":"from_field"}},{"apiName":"personCode","field":"person-code","kind":"set","value":{"field":"person-code","kind":"from_field"}},{"apiName":"personSex","field":"person-sex","kind":"set","value":{"field":"person-sex","kind":"from_field"}},{"apiName":"residencyStatus","field":"residency-status","kind":"set","value":{"field":"residency-status","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"person","kind":"reserved_create"},"entity":"person"}},{"dependsOn":["person"],"id":"effect-3","mutations":[{"apiName":"contactPerson","field":"contact-person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}}],"operation":"patch","target":{"binding":{"fromField":"household","kind":"existing"},"entity":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"apiName":"household","field":"household","kind":"set","value":{"field":"household","kind":"from_field"}},{"apiName":"person","field":"person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}},{"apiName":"relationship","field":"relationship","kind":"set","value":{"field":"relationship","kind":"from_field"}},{"apiName":"validFrom","field":"valid-from","kind":"set","value":{"field":"valid-from","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"membership","kind":"reserved_create"},"entity":"group-membership"}}],"planner":{"kind":"declarative"},"presenceGrants":[{"profileId":"household-operator","requestRowBoundaries":[],"targetEntityId":"household"}],"requestEntity":"register-household-contact-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"household-contact-supervisor","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"group-membership"},{"profileId":"household-contact-supervisor","readableFields":["contact-person"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"household"},{"profileId":"household-contact-supervisor","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"person"},{"profileId":"household-contact-reviewer","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"review","targetEntityId":"group-membership"},{"profileId":"household-contact-reviewer","readableFields":["contact-person"],"rowBoundaries":[],"stage":"review","targetEntityId":"household"},{"profileId":"household-contact-reviewer","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"review","targetEntityId":"person"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["group-membership","household","person"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:register-household-contact-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"familyName":{"maxLength":120,"minLength":0,"type":"string"},"household":{"format":"uuid","type":"string"},"legalName":{"maxLength":160,"minLength":0,"type":"string"},"personCode":{"maxLength":64,"minLength":0,"type":"string"},"personSex":{"enum":["female","male","unknown"],"type":"string","x-registry-vocabulary":"person-sex"},"reason":{"maxLength":1000,"type":"string"},"relationship":{"enum":["head","spouse","child","dependent","other"],"type":"string","x-registry-vocabulary":"household-relationship"},"residencyStatus":{"enum":["usual-resident","temporary-resident","departed"],"type":"string","x-registry-vocabulary":"residency-status"},"validFrom":{"format":"date","type":"string"}},"required":["household","personCode","legalName","familyName","personSex","residencyStatus","relationship","validFrom","reason"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"register-household-contact-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"register-household-contact-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"},{"inputSchema":"register-household-contact-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"final-approval"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"group-membership"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"household"},{"profileId":"household-contact-applier","rowBoundaries":[],"targetEntityId":"person"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:aa19d6c1b31839f2de8a2a9d65315a1c4386285b72a0fe56aebb06b71e2c6537","effects":[{"dependsOn":[],"id":"person","mutations":[{"apiName":"familyName","field":"family-name","kind":"set","value":{"field":"family-name","kind":"from_field"}},{"apiName":"legalName","field":"legal-name","kind":"set","value":{"field":"legal-name","kind":"from_field"}},{"apiName":"personCode","field":"person-code","kind":"set","value":{"field":"person-code","kind":"from_field"}},{"apiName":"personSex","field":"person-sex","kind":"set","value":{"field":"person-sex","kind":"from_field"}},{"apiName":"residencyStatus","field":"residency-status","kind":"set","value":{"field":"residency-status","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"person","kind":"reserved_create"},"entity":"person"}},{"dependsOn":["person"],"id":"effect-3","mutations":[{"apiName":"contactPerson","field":"contact-person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}}],"operation":"patch","target":{"binding":{"fromField":"household","kind":"existing"},"entity":"household"}},{"dependsOn":["person"],"id":"membership","mutations":[{"apiName":"household","field":"household","kind":"set","value":{"field":"household","kind":"from_field"}},{"apiName":"person","field":"person","kind":"set","value":{"effect":"person","kind":"from_effect","targetEntity":"person"}},{"apiName":"relationship","field":"relationship","kind":"set","value":{"field":"relationship","kind":"from_field"}},{"apiName":"validFrom","field":"valid-from","kind":"set","value":{"field":"valid-from","kind":"from_field"}}],"operation":"create","target":{"binding":{"effect":"membership","kind":"reserved_create"},"entity":"group-membership"}}],"planner":{"kind":"declarative"},"presencePermissions":[{"profileId":"household-operator","requestRowBoundaries":[],"targetEntityId":"household"}],"requestEntity":"register-household-contact-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"household-contact-supervisor","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"group-membership"},{"profileId":"household-contact-supervisor","readableFields":["contact-person"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"household"},{"profileId":"household-contact-supervisor","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"final-approval","targetEntityId":"person"},{"profileId":"household-contact-reviewer","readableFields":["household","person","relationship","valid-from"],"rowBoundaries":[],"stage":"review","targetEntityId":"group-membership"},{"profileId":"household-contact-reviewer","readableFields":["contact-person"],"rowBoundaries":[],"stage":"review","targetEntityId":"household"},{"profileId":"household-contact-reviewer","readableFields":["family-name","legal-name","person-code","person-sex","residency-status"],"rowBoundaries":[],"stage":"review","targetEntityId":"person"}],"stages":[{"approvals":1,"excludeSubmitter":true,"id":"review"},{"approvals":1,"excludeSubmitter":true,"id":"final-approval"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["group-membership","household","person"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/request-attachments/generated/openapi.json b/products/breg/generated/request-attachments/generated/openapi.json index b49dfb7258..669402dc93 100644 --- a/products/breg/generated/request-attachments/generated/openapi.json +++ b/products/breg/generated/request-attachments/generated/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"correction-request":{"$id":"urn:breg:entity:correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"},"supporting-file":{"anyOf":[{"type":"null"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"contentType":{"maxLength":255,"pattern":"^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$","type":"string"},"erased":{"const":false},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"},"uploadedAt":{"format":"date-time","type":"string"},"uploadedBy":{"type":"string"},"verificationStatus":{"enum":["notRequired","pending","approved","rejected"],"type":"string"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased","contentType","uploadedAt","uploadedBy","verificationStatus"],"type":"object"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"erased":{"const":true},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased"],"type":"object"}],"readOnly":true,"x-registry-attachment":{"classification":"restricted","contentTypes":["application/pdf"],"maximumBytes":1048576,"requiredForSubmit":true,"verification":{"allowedStatuses":["notRequired","approved"],"pendingOrRejectedBlocks":["download","submit"],"statusField":"verificationStatus"}},"x-registry-fieldKind":"attachment"}},"required":["record","label"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"applier","rowBoundaries":[],"targetEntityId":"record"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2","effects":[{"dependsOn":[],"id":"correct-label","mutations":[{"apiName":"label","field":"label","kind":"set","value":{"field":"label","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"record","kind":"existing"},"entity":"record"}}],"planner":{"kind":"declarative"},"presenceGrants":[],"requestEntity":"correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"reviewer","readableFields":["supporting-file"],"rowBoundaries":[],"stage":"review","targetEntityId":"correction-request"},{"profileId":"reviewer","readableFields":["label"],"rowBoundaries":[],"stage":"review","targetEntityId":"record"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":false,"id":"review"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["record"]},"x-registry-mutationMode":"mutable"},"correction-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"correction-request-create-input":{"$id":"urn:breg:entity:correction-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"}},"required":["record","label"],"type":"object","x-registry-mutationMode":"mutable"},"correction-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"record":{"$id":"urn:breg:entity:record","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2","operations":["patch"],"requestEntity":"correction-request","requestRoute":"correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"record-create-input":{"$id":"urn:breg:entity:record:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-mutationMode":"mutable"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"request-attachments","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/correction-requests":{"get":{"operationId":"records.correction-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"other-owner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"other-owner","selectableProperties":["label","record","supporting-file"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null},"owner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"owner","selectableProperties":["label","record","supporting-file"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.correction-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/correction-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"create","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/correction-requests/{record_id}":{"get":{"operationId":"records.correction-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier","other-owner","owner","reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"get","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.correction-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"patch","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/correction-requests/{record_id}/actions/apply":{"post":{"operationId":"records.correction-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier"],"x-registry-entity":"correction-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"correction-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/correction-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.correction-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"correction-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/correction-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.correction-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.correction-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.correction-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/submit":{"post":{"operationId":"records.correction-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"correction-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/correction-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/attachments/supporting-file":{"delete":{"description":"Mutate one draft slot using request PATCH authority, slot writableFields, owner and row boundaries. Refetch the record after mutation.","operationId":"records.correction-request.patch.attachment.supporting-file.delete","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Fresh request record ETag.","in":"header","name":"If-Match","required":true,"schema":{"type":"string"}},{"description":"Caller-selected mutation replay key.","in":"header","name":"Idempotency-Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-operation":"attachment_delete","x-registry-requiredRequestState":"draft","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"get":{"description":"Download exact proposal content using current request GET authority and slot visibility.","operationId":"records.correction-request.get.attachment.supporting-file.get","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"in":"query","name":"proposalVersion","required":true,"schema":{"maximum":4294967295,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"*/*":{"schema":{"format":"binary","type":"string"}}},"description":"Complete integrity-checked attachment content permitted by its verification policy","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Content-Disposition":{"description":"Attachment disposition with no filename; no slot metadata holds one.","schema":{"const":"attachment"}},"X-Content-Type-Options":{"schema":{"const":"nosniff"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier","other-owner","owner","reviewer"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-operation":"attachment_get","x-registry-responseEntity":"correction-request","x-registry-responseShape":"binary"},"patch":{"description":"Mutate one draft slot using request PATCH authority, slot writableFields, owner and row boundaries. Refetch the record after mutation.","operationId":"records.correction-request.patch.attachment.supporting-file.patch","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Fresh request record ETag.","in":"header","name":"If-Match","required":true,"schema":{"type":"string"}},{"description":"Caller-selected mutation replay key.","in":"header","name":"Idempotency-Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/pdf":{"schema":{"format":"binary","type":"string"}}},"description":"Raw nonempty bytes, at most 1048576 bytes; actual length and SHA-256 are computed by the server.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-maximumBytes":1048576,"x-registry-operation":"attachment_patch","x-registry-requiredRequestState":"draft","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/records":{"get":{"operationId":"records.record.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"operator","selectableProperties":["label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.record.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/record-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"create","x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/records/{record_id}":{"get":{"operationId":"records.record.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"get","x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file +{"components":{"schemas":{"ChangeRequestActionResponse":{"additionalProperties":false,"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"id":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion","effectDigest","appliedAt"],"type":["object","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]}},"required":["bregState","proposalVersion","effectDigest","application"],"type":"object"},"revision":{"format":"int64","minimum":1,"type":"integer"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["id","revision","snapshot","actorReference","request"],"type":"object"},"Problem":{"additionalProperties":false,"allOf":[{"else":{"not":{"required":["refusalCode"]}},"if":{"properties":{"code":{"const":"action.refused"}}},"then":{"properties":{"status":{"const":422}},"required":["refusalCode"]}},{"if":{"properties":{"code":{"const":"action.evidence_failed"}}},"then":{"properties":{"detail":{"const":"The declared Evidence dependency could not be accepted."},"fieldPath":{"pattern":"^/evidence/[a-z][a-z0-9_-]{0,63}$","type":"string"},"status":{"const":503}}}},{"if":{"properties":{"code":{"const":"action.handler_failed"}}},"then":{"properties":{"status":{"const":500}}}},{"if":{"anyOf":[{"required":["entityId"]},{"required":["fieldId"]}]},"then":{"properties":{"code":{"const":"mutation.conflict"},"status":{"const":409}},"required":["entityId","fieldId"]}}],"properties":{"code":{"enum":["action.evidence_failed","action.handler_failed","action.refused","authentication.refused","idempotency.conflict","lookup.unresolved","mutation.conflict","precondition.failed","precondition.required","query.cursor_invalid","query.invalid","request.invalid","request.plan_refused","request.timeout","resource.not_found","service.unavailable","source.unavailable","unsupported.media_type"],"type":"string"},"detail":{"maxLength":256,"type":"string"},"entityId":{"maxLength":128,"minLength":1,"type":"string"},"fieldId":{"maxLength":128,"minLength":1,"type":"string"},"fieldPath":{"maxLength":256,"type":"string"},"refusalCode":{"maxLength":128,"minLength":1,"type":"string"},"status":{"maximum":599,"minimum":400,"type":"integer"},"title":{"maxLength":128,"type":"string"},"traceId":{"maxLength":32,"minLength":32,"pattern":"^[0-9a-f]{32}$","type":"string"},"type":{"format":"uri","maxLength":256,"type":"string"}},"required":["type","title","status","detail","code","traceId"],"type":"object"},"correction-request":{"$id":"urn:breg:entity:correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"},"supporting-file":{"anyOf":[{"type":"null"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"contentType":{"maxLength":255,"pattern":"^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$","type":"string"},"erased":{"const":false},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"},"uploadedAt":{"format":"date-time","type":"string"},"uploadedBy":{"type":"string"},"verificationStatus":{"enum":["notRequired","pending","approved","rejected"],"type":"string"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased","contentType","uploadedAt","uploadedBy","verificationStatus"],"type":"object"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"erased":{"const":true},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased"],"type":"object"}],"readOnly":true,"x-registry-attachment":{"classification":"restricted","contentTypes":["application/pdf"],"maximumBytes":1048576,"requiredForSubmit":true,"verification":{"allowedStatuses":["notRequired","approved"],"pendingOrRejectedBlocks":["download","submit"],"statusField":"verificationStatus"}},"x-registry-fieldKind":"attachment"}},"required":["record","label"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"applier","rowBoundaries":[],"targetEntityId":"record"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a","effects":[{"dependsOn":[],"id":"correct-label","mutations":[{"apiName":"label","field":"label","kind":"set","value":{"field":"label","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"record","kind":"existing"},"entity":"record"}}],"planner":{"kind":"declarative"},"presencePermissions":[],"requestEntity":"correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"reviewer","readableFields":["supporting-file"],"rowBoundaries":[],"stage":"review","targetEntityId":"correction-request"},{"profileId":"reviewer","readableFields":["label"],"rowBoundaries":[],"stage":"review","targetEntityId":"record"}],"stages":[{"approvals":1,"excludeSubmitter":false,"id":"review"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["record"]},"x-registry-mutationMode":"mutable"},"correction-request-apply_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-approve_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-cancel_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"correction-request-create-input":{"$id":"urn:breg:entity:correction-request:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"}},"required":["record","label"],"type":"object","x-registry-mutationMode":"mutable"},"correction-request-reject_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-request_revision-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"effectDigest":{"description":"Digest of the immutable proposal effects displayed to the actor.","pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"}},"required":["proposalVersion","effectDigest"],"type":"object"},"correction-request-submit_request-input":{"$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{},"type":"object"},"record":{"$id":"urn:breg:entity:record","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a","operations":["patch"],"requestEntity":"correction-request","requestRoute":"correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"},"record-create-input":{"$id":"urn:breg:entity:record:input","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-mutationMode":"mutable"}},"securitySchemes":{"bearerAuth":{"bearerFormat":"JWT","scheme":"bearer","type":"http"}}},"info":{"title":"request-attachments","version":"0.1.0"},"openapi":"3.1.0","paths":{"/v1/records/correction-requests":{"get":{"operationId":"records.correction-request.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"other-owner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"other-owner","selectableProperties":["label","record","supporting-file"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null},"owner":{"allowCount":false,"filterableProperties":[{"operators":["equals","in"],"property":"__request_breg_state"},{"operators":["equals","in","range"],"property":"__request_proposal_version"},{"operators":["equals","in","is_null","is_not_null"],"property":"__request_effect_digest"}],"kind":"list","maxPageSize":100,"profile":"owner","selectableProperties":["label","record","supporting-file"],"selectorProperties":[],"sortableProperties":[{"directions":["asc"],"property":"__request_breg_state"},{"directions":["asc"],"property":"__request_proposal_version"},{"directions":["asc"],"property":"__request_effect_digest"}],"temporal":null}},"x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.correction-request.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/correction-request-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"create","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/correction-requests/{record_id}":{"get":{"operationId":"records.correction-request.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Positive proposal-version cursor for the request history page returned with this request record.","explode":false,"in":"query","name":"requestHistoryAfterProposalVersion","required":false,"schema":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"allOf":[{"else":{"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"}}},"if":{"properties":{"request":{"properties":{"detailErased":{"const":true}},"required":["detailErased"],"type":"object"}},"required":["request"]},"then":{"properties":{"domainData":{"additionalProperties":false,"maxProperties":0,"type":"object"}}}}],"properties":{"domainData":{"type":"object"},"recordIdentifier":{"format":"uuid","type":"string"},"request":{"additionalProperties":false,"properties":{"actions":{"items":{"additionalProperties":false,"properties":{"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"href":{"maxLength":2048,"type":"string"},"ifMatch":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"},"method":{"const":"POST"},"operation":{"enum":["submit_request","approve_request","reject_request","request_revision","revise_request","cancel_request","apply_request"],"type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"rebase":{"type":"boolean"},"review":{"additionalProperties":false,"properties":{"targets":{"items":{"additionalProperties":false,"properties":{"after":{"additionalProperties":true,"maxProperties":128,"type":"object"},"baseRevision":{"format":"int64","minimum":1,"type":["integer","null"]},"before":{"additionalProperties":true,"maxProperties":128,"type":["object","null"]},"entityId":{"type":"string"},"operation":{"enum":["create","patch"],"type":"string"},"recordId":{"format":"uuid","type":"string"}},"required":["entityId","recordId","operation","baseRevision","before","after"],"type":"object"},"maxItems":16,"type":"array"}},"required":["targets"],"type":"object"},"stage":{"minLength":1,"type":"string"}},"required":["operation","method","href","ifMatch"],"type":"object"},"maxItems":64,"type":"array"},"application":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":"string"},"appliedAt":{"format":"date-time","type":"string"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"}},"required":["applicationId","proposalVersion"],"type":["object","null"]},"applierReference":{"maxLength":512,"minLength":1,"type":"string"},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"const":true},"editable":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":["string","null"]},"history":{"additionalProperties":false,"properties":{"nextAfterProposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":["integer","null"]},"proposals":{"items":{"additionalProperties":false,"properties":{"applicationId":{"format":"uuid","type":["string","null"]},"bregState":{"enum":["draft","submitted","approved","needs_changes","rejected","canceled","applied"],"type":"string"},"contractFingerprint":{"type":"string"},"current":{"type":"boolean"},"decisions":{"items":{"additionalProperties":false,"allOf":[{"if":{"properties":{"kind":{"const":"approve"}}},"then":{"properties":{"reasonPresent":{"const":false}}}},{"if":{"properties":{"reasonPresent":{"const":false}}},"then":{"not":{"required":["reason"]}}}],"properties":{"actorReference":{"maxLength":512,"minLength":1,"type":"string"},"decidedAt":{"format":"date-time","maxLength":128,"type":"string"},"kind":{"enum":["approve","reject","request_revision"]},"reason":{"description":"Optional reviewer explanation, preserved unchanged. At most 4096 Unicode characters; NUL is refused.","maxLength":4096,"pattern":"^[^\\u0000]*$","type":"string"},"reasonPresent":{"type":"boolean"},"stageId":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["stageId","kind","decidedAt","reasonPresent"],"type":"object"},"maxItems":1024,"type":"array"},"detailErased":{"type":"boolean"},"effectDigest":{"pattern":"^sha256:[0-9a-f]{64}$","type":"string"},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"requestEntityId":{"type":"string"},"requestId":{"format":"uuid","type":"string"},"resultLinkCount":{"maximum":16,"minimum":0,"type":"integer"},"resultLinks":{"items":{"additionalProperties":false,"properties":{"targetEntityId":{"type":"string"},"targetRecordId":{"format":"uuid","type":"string"},"targetRevision":{"format":"int64","minimum":1,"type":"integer"}},"required":["targetEntityId","targetRecordId","targetRevision"],"type":"object"},"maxItems":16,"type":"array"}},"required":["requestEntityId","requestId","proposalVersion","bregState","current","contractFingerprint","detailErased","applicationId","resultLinkCount","resultLinks"],"type":"object"},"maxItems":50,"type":"array"}},"required":["proposals","nextAfterProposalVersion"],"type":"object"},"proposal":{"oneOf":[{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"apply"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"additionalProperties":false,"properties":{"applicationDisposition":{"const":"queue"},"queueReason":{"additionalProperties":false,"properties":{"code":{"maxLength":128,"type":"string"},"label":{"maxLength":160,"minLength":1,"type":"string"}},"required":["code","label"],"type":"object"},"reviewMode":{"enum":["none","staged"],"type":"string"}},"required":["reviewMode","applicationDisposition"],"type":"object"},{"type":"null"}]},"proposalVersion":{"format":"int64","maximum":4294967295,"minimum":1,"type":"integer"},"review":{"additionalProperties":false,"properties":{"pendingStage":{"anyOf":[{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"},{"type":"null"}]},"stageEnteredAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"stages":{"items":{"additionalProperties":false,"properties":{"approvals":{"maximum":32,"minimum":1,"type":"integer"},"excludePreviousReviewers":{"type":"boolean"},"excludeSubmitter":{"type":"boolean"},"id":{"maxLength":64,"minLength":1,"not":{"pattern":"[^a-z0-9_-]"},"pattern":"^[a-z]","type":"string"}},"required":["id","approvals","excludeSubmitter"],"type":"object"},"maxItems":32,"type":"array"},"submittedAt":{"format":"date-time","maxLength":128,"type":"string"}},"required":["stages","submittedAt","pendingStage","stageEnteredAt"],"type":"object"},"reviewTiming":{"additionalProperties":false,"properties":{"completedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"firstSubmittedAt":{"format":"date-time","maxLength":128,"type":"string"},"pauseStartedAt":{"format":"date-time","maxLength":128,"type":["string","null"]},"pausedMilliseconds":{"format":"int64","maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["firstSubmittedAt","pausedMilliseconds","pauseStartedAt","completedAt"],"type":"object"},"submitterReference":{"maxLength":512,"minLength":1,"type":"string"}},"required":["bregState","proposalVersion","editable"],"type":"object"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier","other-owner","owner","reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"get","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"patch":{"operationId":"records.correction-request.patch","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json-patch+json":{"schema":{"items":{"additionalProperties":false,"else":{"required":["value"]},"if":{"properties":{"op":{"const":"remove"}}},"properties":{"op":{"enum":["add","replace","remove","test"],"type":"string"},"path":{"type":"string"},"value":true},"required":["op","path"],"then":{"not":{"required":["value"]}},"type":"object"},"maxItems":128,"minItems":1,"type":"array"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"patch","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/correction-requests/{record_id}/actions/apply":{"post":{"operationId":"records.correction-request.request.apply","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-apply_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier"],"x-registry-entity":"correction-request","x-registry-operation":"apply_request","x-registry-requestAction":{"inputSchema":"correction-request-apply_request-input","method":"post","operation":"apply_request","path":"/v1/records/correction-requests/{record_id}/actions/apply","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/cancel":{"post":{"operationId":"records.correction-request.request.cancel","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-cancel_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"cancel_request","x-registry-requestAction":{"inputSchema":"correction-request-cancel_request-input","method":"post","operation":"cancel_request","path":"/v1/records/correction-requests/{record_id}/actions/cancel","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/approve":{"post":{"operationId":"records.correction-request.request.stages.review.approve","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-approve_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"approve_request","x-registry-requestAction":{"inputSchema":"correction-request-approve_request-input","method":"post","operation":"approve_request","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/approve","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/reject":{"post":{"operationId":"records.correction-request.request.stages.review.reject","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-reject_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"reject_request","x-registry-requestAction":{"inputSchema":"correction-request-reject_request-input","method":"post","operation":"reject_request","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/reject","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/stages/review/request-revision":{"post":{"operationId":"records.correction-request.request.stages.review.request_revision","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-request_revision-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["reviewer"],"x-registry-entity":"correction-request","x-registry-operation":"request_revision","x-registry-requestAction":{"inputSchema":"correction-request-request_revision-input","method":"post","operation":"request_revision","path":"/v1/records/correction-requests/{record_id}/actions/stages/review/request-revision","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match","proposalVersion","effectDigest"],"responseSchema":"ChangeRequestActionResponse","stage":"review","targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/actions/submit":{"post":{"operationId":"records.correction-request.request.submit","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":true,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, request record, package revision, action body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Strong Registry ETag for the currently visible request record representation.","in":"header","name":"If-Match","required":true,"schema":{"maxLength":256,"minLength":6,"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/correction-request-submit_request-input"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRequestActionResponse"}}},"description":"Request action accepted","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}},"request.plan_refused":{"value":{"code":"request.plan_refused","detail":"The change-request planner refused the submission: change_request.planner.execution.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/plan_refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-entity":"correction-request","x-registry-operation":"submit_request","x-registry-requestAction":{"inputSchema":"correction-request-submit_request-input","method":"post","operation":"submit_request","path":"/v1/records/correction-requests/{record_id}/actions/submit","proposalBinding":{"digestField":"effectDigest","idempotencyHeader":"Idempotency-Key","recordPrecondition":"If-Match","versionField":"proposalVersion"},"requestEntity":"correction-request","requiredPreconditions":["Idempotency-Key","If-Match"],"responseSchema":"ChangeRequestActionResponse","stage":null,"targetEntities":["record"]},"x-registry-responseEntity":"correction-request","x-registry-responseShape":"BRegChangeRequestActionResponseV1"}},"/v1/records/correction-requests/{record_id}/attachments/supporting-file":{"delete":{"description":"Mutate one draft slot using request PATCH authority, slot writableFields, owner and row boundaries. Refetch the record after mutation.","operationId":"records.correction-request.patch.attachment.supporting-file.delete","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Fresh request record ETag.","in":"header","name":"If-Match","required":true,"schema":{"type":"string"}},{"description":"Caller-selected mutation replay key.","in":"header","name":"Idempotency-Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-operation":"attachment_delete","x-registry-requiredRequestState":"draft","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"},"get":{"description":"Download exact proposal content using current request GET authority and slot visibility.","operationId":"records.correction-request.get.attachment.supporting-file.get","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"in":"query","name":"proposalVersion","required":true,"schema":{"maximum":4294967295,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"*/*":{"schema":{"format":"binary","type":"string"}}},"description":"Complete integrity-checked attachment content permitted by its verification policy","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Content-Disposition":{"description":"Attachment disposition with no filename; no slot metadata holds one.","schema":{"const":"attachment"}},"X-Content-Type-Options":{"schema":{"const":"nosniff"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["applier","other-owner","owner","reviewer"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-operation":"attachment_get","x-registry-responseEntity":"correction-request","x-registry-responseShape":"binary"},"patch":{"description":"Mutate one draft slot using request PATCH authority, slot writableFields, owner and row boundaries. Refetch the record after mutation.","operationId":"records.correction-request.patch.attachment.supporting-file.patch","parameters":[{"in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"query","name":"accessProfile","required":false,"schema":{"type":"string"}},{"description":"Fresh request record ETag.","in":"header","name":"If-Match","required":true,"schema":{"type":"string"}},{"description":"Caller-selected mutation replay key.","in":"header","name":"Idempotency-Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/pdf":{"schema":{"format":"binary","type":"string"}}},"description":"Raw nonempty bytes, at most 1048576 bytes; actual length and SHA-256 are computed by the server.","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/correction-request"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"correction-request"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record patched","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"412":{"content":{"application/problem+json":{"examples":{"precondition.failed":{"value":{"code":"precondition.failed","detail":"The mutation precondition failed.","status":412,"title":"Precondition Failed","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/failed"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"428":{"content":{"application/problem+json":{"examples":{"precondition.required":{"value":{"code":"precondition.required","detail":"The mutation precondition is required.","status":428,"title":"Precondition Required","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/precondition/required"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["other-owner","owner"],"x-registry-attachmentSlot":"supporting-file","x-registry-entity":"correction-request","x-registry-maximumBytes":1048576,"x-registry-operation":"attachment_patch","x-registry-requiredRequestState":"draft","x-registry-responseEntity":"correction-request","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/records":{"get":{"operationId":"records.record.list","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Strict Registry read filter expression over compiled filterable API properties.","explode":false,"in":"query","name":"$filter","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"One compiled sortable property, ascending only.","explode":false,"in":"query","name":"$orderby","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Bounded page size.","explode":false,"in":"query","name":"$top","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer"}},{"description":"Request count when the selected compiled query profile allows it.","explode":false,"in":"query","name":"$count","required":false,"schema":{"type":"boolean"}},{"description":"Opaque continuation cursor for the next page.","explode":false,"in":"query","name":"$skiptoken","required":false,"schema":{"maxLength":4096,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"count":{"format":"int64","minimum":0,"type":"integer"},"items":{"items":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"type":"array"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"},"pageInfo":{"additionalProperties":false,"properties":{"nextCursor":{"maxLength":4096,"type":["string","null"]}},"required":["nextCursor"],"type":"object"}},"required":["items","pageInfo","meta","@context"],"type":"object"}}},"description":"Records returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"query.cursor_invalid":{"value":{"code":"query.cursor_invalid","detail":"The query cursor is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/cursor_invalid"}},"query.invalid":{"value":{"code":"query.invalid","detail":"The query request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/query/invalid"}},"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"list","x-registry-queryKind":"list","x-registry-queryProfiles":{"operator":{"allowCount":false,"filterableProperties":[],"kind":"list","maxPageSize":100,"profile":"operator","selectableProperties":["label"],"selectorProperties":[],"sortableProperties":[],"temporal":null}},"x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordCollectionV1"},"post":{"operationId":"records.record.create","parameters":[{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Idempotency key bound to method, route, caller, target record, package revision, request body, and response field set.","in":"header","name":"Idempotency-Key","required":true,"schema":{"maxLength":256,"minLength":1,"pattern":"^[\\x21-\\x2B\\x2D-\\x3A\\x3C-\\x7E]+$","type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/record-create-input"}},"required":["data"],"type":"object"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"},"snapshot":{"maxLength":4096,"type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData","snapshot"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record created","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Location":{"description":"Relative URL of the created record.","schema":{"type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"409":{"content":{"application/problem+json":{"examples":{"idempotency.conflict":{"value":{"code":"idempotency.conflict","detail":"The idempotency key is bound to another request.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/idempotency/conflict"}},"mutation.conflict":{"value":{"code":"mutation.conflict","detail":"The mutation conflicts with current state.","status":409,"title":"Conflict","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/mutation/conflict"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"415":{"content":{"application/problem+json":{"examples":{"unsupported.media_type":{"value":{"code":"unsupported.media_type","detail":"The request media type is not supported.","status":415,"title":"Unsupported Media Type","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/unsupported/media_type"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"service.unavailable":{"value":{"code":"service.unavailable","detail":"The Registry mutation service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/service/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"create","x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}},"/v1/records/records/{record_id}":{"get":{"operationId":"records.record.get","parameters":[{"description":"Canonical record UUID.","in":"path","name":"record_id","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Optional W3C trace context. Responses carry Registry trace context for the request.","in":"header","name":"traceparent","required":false,"schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}},{"description":"Select one compiled access profile. Required when the route has no default; otherwise omission uses its default.","explode":false,"in":"query","name":"accessProfile","required":false,"schema":{"maxLength":128,"type":"string"}},{"description":"Comma-separated subset of readable API property names.","explode":false,"in":"query","name":"$select","required":false,"schema":{"maxLength":16384,"type":"string"}},{"description":"Choose the ordinary JSON or JSON-LD Registry Record representation.","in":"header","name":"Accept","required":false,"schema":{"enum":["application/json","application/ld+json"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta"],"type":"object"}},"application/ld+json":{"schema":{"additionalProperties":false,"properties":{"@context":{"const":"https://id.registrystack.org/contexts/registry-record/v1"},"data":{"additionalProperties":false,"properties":{"domainData":{"$ref":"#/components/schemas/record"},"recordIdentifier":{"format":"uuid","type":"string"},"revisionIdentifier":{"pattern":"^[1-9][0-9]*$","type":"string"}},"required":["recordIdentifier","revisionIdentifier","domainData"],"type":"object"},"meta":{"additionalProperties":false,"properties":{"datasetIdentifier":{"const":"request-attachments"},"entityTypeIdentifier":{"const":"record"},"registryIdentifier":{"const":"request-attachments"}},"required":["registryIdentifier","datasetIdentifier","entityTypeIdentifier"],"type":"object"}},"required":["data","meta","@context"],"type":"object"}}},"description":"Record returned","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"ETag":{"description":"Strong Registry ETag bound to the record, package revision, caller profile, and response field set.","schema":{"pattern":"^\\\"breg-[\\x21\\x23-\\x7E]+\\\"$","type":"string"}},"Link":{"description":"Emitted only for application/json and application/ld+json Registry Record responses and omitted for application/geo+json. Carries the Registry Record profile and caller-visible entity schema. The describedby target includes the configured deployment prefix and is never derived from Host or forwarded headers.","schema":{"example":"; rel=\"profile\", ; rel=\"describedby\"","type":"string"}},"Vary":{"description":"Responses vary by authorization and negotiated representation.","schema":{"type":"string"}},"traceparent":{"description":"Trace context for this response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"400":{"content":{"application/problem+json":{"examples":{"request.invalid":{"value":{"code":"request.invalid","detail":"The request is invalid.","status":400,"title":"Bad Request","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/invalid"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"401":{"content":{"application/problem+json":{"examples":{"authentication.refused":{"value":{"code":"authentication.refused","detail":"The bearer credential is missing or refused.","status":401,"title":"Unauthorized","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/authentication/refused"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"404":{"content":{"application/problem+json":{"examples":{"resource.not_found":{"value":{"code":"resource.not_found","detail":"The requested resource was not found.","status":404,"title":"Not Found","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/resource/not_found"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"503":{"content":{"application/problem+json":{"examples":{"source.unavailable":{"value":{"code":"source.unavailable","detail":"The Registry data service is unavailable.","status":503,"title":"Service Unavailable","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/source/unavailable"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}},"504":{"content":{"application/problem+json":{"examples":{"request.timeout":{"value":{"code":"request.timeout","detail":"The request timed out.","status":504,"title":"Gateway Timeout","traceId":"11111111111111111111111111111111","type":"https://id.registrystack.org/problems/registry-breg/request/timeout"}}},"schema":{"$ref":"#/components/schemas/Problem"}}},"description":"Problem response","headers":{"Cache-Control":{"description":"Caller-dependent responses must not be stored.","schema":{"const":"no-store"}},"traceparent":{"description":"Trace context for this problem response.","example":"00-11111111111111111111111111111111-2222222222222222-01","schema":{"maxLength":55,"minLength":55,"pattern":"^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$","type":"string"}}}}},"security":[{"bearerAuth":[]}],"x-registry-accessProfiles":["operator"],"x-registry-entity":"record","x-registry-operation":"get","x-registry-responseEntity":"record","x-registry-responseProfile":"https://id.registrystack.org/profiles/registry-record/v1","x-registry-responseShape":"RegistryRecordSingleV1"}}}} \ No newline at end of file diff --git a/products/breg/generated/request-attachments/generated/postgres/schema.sql b/products/breg/generated/request-attachments/generated/postgres/schema.sql index c7394b123d..9d64678d01 100644 --- a/products/breg/generated/request-attachments/generated/postgres/schema.sql +++ b/products/breg/generated/request-attachments/generated/postgres/schema.sql @@ -77,7 +77,7 @@ CREATE POLICY "registry_rls_select_b40bcde7c0ab8f804497021f" ON registry_data."b AND cr_state.detail_erased_at IS NOT NULL ) )) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb IS NULL); -CREATE POLICY "registry_cr_action_rls_select_8be6b549b54424805b834167" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_8be6b549b54424805b834167" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -85,14 +85,14 @@ CREATE POLICY "registry_cr_action_rls_select_8be6b549b54424805b834167" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled', 'applied') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_4098c16d37cb7f96d2980d9e" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_4098c16d37cb7f96d2980d9e" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'apply_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.apply' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -100,7 +100,7 @@ CREATE POLICY "registry_cr_action_rls_update_4098c16d37cb7f96d2980d9e" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('approved') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_fbd8f020b1beb2301e8223b9" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_fbd8f020b1beb2301e8223b9" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -108,14 +108,14 @@ CREATE POLICY "registry_cr_action_rls_select_fbd8f020b1beb2301e8223b9" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_292224683752396c0ed53710" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_292224683752396c0ed53710" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -123,7 +123,7 @@ CREATE POLICY "registry_cr_action_rls_update_292224683752396c0ed53710" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_9d64420e280627635173103e" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_9d64420e280627635173103e" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -131,7 +131,7 @@ CREATE POLICY "registry_cr_action_rls_select_9d64420e280627635173103e" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_af44c8002602a48daf6d5d33" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_af44c8002602a48daf6d5d33" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -139,7 +139,7 @@ CREATE POLICY "registry_cr_action_rls_update_af44c8002602a48daf6d5d33" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -148,7 +148,7 @@ CREATE POLICY "registry_cr_action_rls_update_af44c8002602a48daf6d5d33" ON regist AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_be4d371baf868b21e3625137" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_be4d371baf868b21e3625137" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -156,14 +156,14 @@ CREATE POLICY "registry_cr_action_rls_select_be4d371baf868b21e3625137" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_324e8143e7e9df7996c62a01" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_324e8143e7e9df7996c62a01" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'submit_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.submit' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -171,7 +171,7 @@ CREATE POLICY "registry_cr_action_rls_update_324e8143e7e9df7996c62a01" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_b4ee9856928a5dabf47364e4" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_b4ee9856928a5dabf47364e4" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -179,7 +179,7 @@ CREATE POLICY "registry_cr_action_rls_select_b4ee9856928a5dabf47364e4" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_9ee9d7139baa6c1b8b5bfd2b" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_9ee9d7139baa6c1b8b5bfd2b" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -187,7 +187,7 @@ CREATE POLICY "registry_cr_action_rls_update_9ee9d7139baa6c1b8b5bfd2b" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'cancel_request' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage') IS NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.cancel' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -196,7 +196,7 @@ CREATE POLICY "registry_cr_action_rls_update_9ee9d7139baa6c1b8b5bfd2b" ON regist AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected') AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_7e890f6ea52f60260d3bd259" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_7e890f6ea52f60260d3bd259" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -204,14 +204,14 @@ CREATE POLICY "registry_cr_action_rls_select_7e890f6ea52f60260d3bd259" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_abd838d43b12af0a872009ed" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_abd838d43b12af0a872009ed" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'approve_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.approve' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -219,7 +219,7 @@ CREATE POLICY "registry_cr_action_rls_update_abd838d43b12af0a872009ed" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_57c6c1512b8dade2451bcbf5" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_57c6c1512b8dade2451bcbf5" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -227,14 +227,14 @@ CREATE POLICY "registry_cr_action_rls_select_57c6c1512b8dade2451bcbf5" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_9e05fc70e43c9b745b06f96d" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_9e05fc70e43c9b745b06f96d" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'reject_request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.reject' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -242,7 +242,7 @@ CREATE POLICY "registry_cr_action_rls_update_9e05fc70e43c9b745b06f96d" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_select_af27531587bc6cf00b8011ce" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_select_af27531587bc6cf00b8011ce" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -250,14 +250,14 @@ CREATE POLICY "registry_cr_action_rls_select_af27531587bc6cf00b8011ce" ON regist AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('draft', 'submitted', 'approved', 'needs_changes', 'rejected', 'canceled') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_action_rls_update_1c9c1f24828a420e5730b97c" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( +CREATE POLICY "registry_cr_action_rls_update_1c9c1f24828a420e5730b97c" ON registry_data."breg_e_correction_request_0ee31b2fadfee29d" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') AND cr_state.request_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId')::uuid AND cr_state.proposal_version = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'proposalVersion')::bigint AND cr_state.state IN ('submitted') - ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( + ) AND record_lifecycle = 'active') WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'operation' = 'request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'stage' = 'review' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'routeId' = 'records.correction-request.request.stages.review.request_revision' AND NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0 AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_action_context', true), '')::jsonb ->> 'requestEntityId') @@ -269,7 +269,7 @@ ALTER TABLE registry_data."breg_e_record_da7a7e7c2436c900" ENABLE ROW LEVEL SECU ALTER TABLE registry_data."breg_e_record_da7a7e7c2436c900" FORCE ROW LEVEL SECURITY; CREATE POLICY "registry_rls_select_24f8f1c6e7fc3e397ebc0ca7" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING ((NULLIF(current_setting('registry.access_profile', true), '') = 'operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND (record_lifecycle = 'active')); CREATE POLICY "registry_rls_insert_65f5bcf51d715eab0bbc4dd1" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR INSERT WITH CHECK ((NULLIF(current_setting('registry.access_profile', true), '') = 'operator' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND jsonb_typeof(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 'array' AND jsonb_array_length(NULLIF(current_setting('registry.row_boundaries', true), '')::jsonb) = 0) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_d3c713eb2d4298fd966329ab" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_d3c713eb2d4298fd966329ab" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'other-owner' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'other-owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -278,7 +278,7 @@ CREATE POLICY "registry_cr_rls_select_d3c713eb2d4298fd966329ab" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_7637089c5d1aef6d01c8f9bd" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( +CREATE POLICY "registry_cr_rls_select_7637089c5d1aef6d01c8f9bd" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'owner' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"preparation"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'owner' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries' = '[]'::jsonb AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state WHERE cr_state.request_entity_id = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId') @@ -287,7 +287,7 @@ CREATE POLICY "registry_cr_rls_select_7637089c5d1aef6d01c8f9bd" ON registry_data AND cr_state.state = 'draft' AND cr_state.owner_reference = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') ) AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_6599f0d734927ff0b1f7be9e" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_6599f0d734927ff0b1f7be9e" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'reviewer' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"review","stage":"review"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'reviewer' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -313,7 +313,7 @@ CREATE POLICY "registry_cr_rls_select_6599f0d734927ff0b1f7be9e" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_select_f39d082ecb35e04ea6454bb4" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_select_f39d082ecb35e04ea6454bb4" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR SELECT USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -339,7 +339,7 @@ CREATE POLICY "registry_cr_rls_select_f39d082ecb35e04ea6454bb4" ON registry_data OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active'); -CREATE POLICY "registry_cr_rls_update_76644d84ef6aea1792564dcf" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL +CREATE POLICY "registry_cr_rls_update_76644d84ef6aea1792564dcf" ON registry_data."breg_e_record_da7a7e7c2436c900" FOR UPDATE USING (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state @@ -364,7 +364,7 @@ CREATE POLICY "registry_cr_rls_update_76644d84ef6aea1792564dcf" ON registry_data (cr_target.expected_revision IS NULL AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NULL) OR cr_target.expected_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint ) - ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL + ) AND jsonb_typeof((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 'array' AND jsonb_array_length((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'targetRowBoundaries')) = 0 AND record_lifecycle = 'active' AND record_revision = (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision')::bigint) WITH CHECK (jsonb_typeof(NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb) = 'object' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'version' = '1' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'requestEntityId' = 'correction-request' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'contractFingerprint' = 'sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectDigest') ~ '^sha256:[0-9a-f]{64}$' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'actorReference') IS NOT NULL AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile' = 'applier' AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'selectedAccessProfile') = NULLIF(current_setting('registry.access_profile', true), '') AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'principal') = NULLIF(current_setting('registry.principal', true), '')) AND ((NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') IS NULL OR (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'purpose') = NULLIF(current_setting('registry.purpose', true), '')) AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'effectId' = 'correct-label' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetEntityId' = 'record' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'operation' = 'patch' AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'fields' = '["label"]'::jsonb AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'activePackageRevision' = NULLIF(current_setting('registry.active_package_revision', true), '') AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'targetRecordId' = "record_id"::text AND NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb -> 'phase' = '{"kind":"application"}'::jsonb AND NULLIF(current_setting('registry.access_profile', true), '') = 'applier' AND NULLIF(current_setting('registry.principal', true), '') IS NOT NULL AND NULLIF(current_setting('registry.purpose', true), '') IN ('attachment-acceptance') AND (NULLIF(current_setting('registry.change_request_target_context', true), '')::jsonb ->> 'expectedRevision') IS NOT NULL AND EXISTS ( SELECT 1 FROM registry_internal.registry_request_state AS cr_state diff --git a/products/breg/generated/request-attachments/generated/schemas/correction-request.schema.json b/products/breg/generated/request-attachments/generated/schemas/correction-request.schema.json index 16e503763b..9508078cb9 100644 --- a/products/breg/generated/request-attachments/generated/schemas/correction-request.schema.json +++ b/products/breg/generated/request-attachments/generated/schemas/correction-request.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"},"supporting-file":{"anyOf":[{"type":"null"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"contentType":{"maxLength":255,"pattern":"^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$","type":"string"},"erased":{"const":false},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"},"uploadedAt":{"format":"date-time","type":"string"},"uploadedBy":{"type":"string"},"verificationStatus":{"enum":["notRequired","pending","approved","rejected"],"type":"string"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased","contentType","uploadedAt","uploadedBy","verificationStatus"],"type":"object"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"erased":{"const":true},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased"],"type":"object"}],"readOnly":true,"x-registry-attachment":{"classification":"restricted","contentTypes":["application/pdf"],"maximumBytes":1048576,"requiredForSubmit":true,"verification":{"allowedStatuses":["notRequired","approved"],"pendingOrRejectedBlocks":["download","submit"],"statusField":"verificationStatus"}},"x-registry-fieldKind":"attachment"}},"required":["record","label"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyGrants":[{"profileId":"applier","rowBoundaries":[],"targetEntityId":"record"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2","effects":[{"dependsOn":[],"id":"correct-label","mutations":[{"apiName":"label","field":"label","kind":"set","value":{"field":"label","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"record","kind":"existing"},"entity":"record"}}],"planner":{"kind":"declarative"},"presenceGrants":[],"requestEntity":"correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewGrants":[{"profileId":"reviewer","readableFields":["supporting-file"],"rowBoundaries":[],"stage":"review","targetEntityId":"correction-request"},{"profileId":"reviewer","readableFields":["label"],"rowBoundaries":[],"stage":"review","targetEntityId":"record"}],"reviewMode":"staged","stages":[{"approvals":1,"excludeSubmitter":false,"id":"review"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["record"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:correction-request","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"},"record":{"format":"uuid","type":"string"},"supporting-file":{"anyOf":[{"type":"null"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"contentType":{"maxLength":255,"pattern":"^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$","type":"string"},"erased":{"const":false},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"},"uploadedAt":{"format":"date-time","type":"string"},"uploadedBy":{"type":"string"},"verificationStatus":{"enum":["notRequired","pending","approved","rejected"],"type":"string"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased","contentType","uploadedAt","uploadedBy","verificationStatus"],"type":"object"},{"additionalProperties":false,"properties":{"byteSize":{"maximum":16777216,"minimum":1,"type":"integer"},"erased":{"const":true},"filled":{"const":true},"proposalVersion":{"maximum":4294967295,"minimum":1,"type":"integer"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"slotId":{"const":"supporting-file"}},"required":["slotId","proposalVersion","filled","sha256","byteSize","erased"],"type":"object"}],"readOnly":true,"x-registry-attachment":{"classification":"restricted","contentTypes":["application/pdf"],"maximumBytes":1048576,"requiredForSubmit":true,"verification":{"allowedStatuses":["notRequired","approved"],"pendingOrRejectedBlocks":["download","submit"],"statusField":"verificationStatus"}},"x-registry-fieldKind":"attachment"}},"required":["record","label"],"type":"object","x-registry-changeRequest":{"actions":[{"inputSchema":"correction-request-submit_request-input","method":"POST","operation":"submit_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-revise_request-input","method":"POST","operation":"revise_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-cancel_request-input","method":"POST","operation":"cancel_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-apply_request-input","method":"POST","operation":"apply_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":null},{"inputSchema":"correction-request-approve_request-input","method":"POST","operation":"approve_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-reject_request-input","method":"POST","operation":"reject_request","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"},{"inputSchema":"correction-request-request_revision-input","method":"POST","operation":"request_revision","requiresIdempotencyKey":true,"requiresRecordPrecondition":true,"responseSchema":"ChangeRequestActionResponse","stage":"review"}],"application":{"allowedDispositions":["queue"],"mode":"manual","queueReasons":[]},"applyPermissions":[{"profileId":"applier","rowBoundaries":[],"targetEntityId":"record"}],"bounds":{"maximumFieldMutations":128,"maximumSnapshotBytes":2097152,"maximumTargets":16},"contractFingerprint":"sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a","effects":[{"dependsOn":[],"id":"correct-label","mutations":[{"apiName":"label","field":"label","kind":"set","value":{"field":"label","kind":"from_field"}}],"operation":"patch","target":{"binding":{"fromField":"record","kind":"existing"},"entity":"record"}}],"planner":{"kind":"declarative"},"presencePermissions":[],"requestEntity":"correction-request","retention":{"effectivePolicy":{"erasedDetailMarker":"request.detailErased","payloadSnapshots":"operator_erasable_after_terminal_state","provenanceStub":"retained_while_target_revisions_reference_request"},"mode":"operator_erase"},"reviewMode":"staged","reviewPermissions":[{"profileId":"reviewer","readableFields":["supporting-file"],"rowBoundaries":[],"stage":"review","targetEntityId":"correction-request"},{"profileId":"reviewer","readableFields":["label"],"rowBoundaries":[],"stage":"review","targetEntityId":"record"}],"stages":[{"approvals":1,"excludeSubmitter":false,"id":"review"}],"stateEnvelope":{"actionAvailability":"advisory_rechecked_on_use","proposalBinding":["proposalVersion","effectDigest","contractFingerprint"],"states":["draft","submitted","approved","needs_changes","rejected","canceled","applied"]},"targetEntities":["record"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/request-attachments/generated/schemas/record.schema.json b/products/breg/generated/request-attachments/generated/schemas/record.schema.json index c89e87b6ad..83011a9b88 100644 --- a/products/breg/generated/request-attachments/generated/schemas/record.schema.json +++ b/products/breg/generated/request-attachments/generated/schemas/record.schema.json @@ -1 +1 @@ -{"$id":"urn:breg:entity:record","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:7134dacb6d6195b5f7c723fe2eafc9a2f0f369d52840528821a2394e2a334fa2","operations":["patch"],"requestEntity":"correction-request","requestRoute":"correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file +{"$id":"urn:breg:entity:record","$schema":"https://json-schema.org/draft/2020-12/schema","additionalProperties":false,"properties":{"label":{"maxLength":64,"minLength":0,"type":"string"}},"required":["label"],"type":"object","x-registry-changeControl":{"directWriteRestriction":"controlled_operations_require_compiled_change_request_application","eligibleRequestTypes":[{"contractFingerprint":"sha256:c9fe98059d5a7dc59129c6b86be67c38c67ca2df5804da1ef034d57b4d0db63a","operations":["patch"],"requestEntity":"correction-request","requestRoute":"correction-requests"}],"requiredFor":["patch"]},"x-registry-mutationMode":"mutable"} \ No newline at end of file diff --git a/products/breg/generated/runtime/runtime.schema.json b/products/breg/generated/runtime/runtime.schema.json index 7325ea2e41..1bb17f70f1 100644 --- a/products/breg/generated/runtime/runtime.schema.json +++ b/products/breg/generated/runtime/runtime.schema.json @@ -252,6 +252,17 @@ "RawAuthorityClaimsConfig": { "additionalProperties": false, "properties": { + "contextual": { + "anyOf": [ + { + "$ref": "#/$defs/RawContextualClaimNames" + }, + { + "type": "null" + } + ], + "default": null + }, "principal": { "maxLength": 128, "minLength": 1, @@ -293,6 +304,13 @@ "string", "null" ] + }, + "trustedActors": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" } }, "required": [ @@ -300,6 +318,54 @@ ], "type": "object" }, + "RawContextualClaimNames": { + "additionalProperties": false, + "properties": { + "actorKind": { + "type": "string" + }, + "approver": { + "type": "string" + }, + "grantAuthority": { + "type": "string" + }, + "grantBounds": { + "type": "string" + }, + "grantClient": { + "type": "string" + }, + "grantExp": { + "type": "string" + }, + "grantId": { + "type": "string" + }, + "grantResource": { + "type": "string" + }, + "grantSourceIssuer": { + "type": "string" + }, + "purpose": { + "type": "string" + } + }, + "required": [ + "actorKind", + "purpose", + "grantId", + "grantAuthority", + "grantSourceIssuer", + "grantClient", + "grantResource", + "grantExp", + "grantBounds", + "approver" + ], + "type": "object" + }, "RawCursorConfig": { "additionalProperties": false, "properties": { @@ -975,6 +1041,48 @@ "runtime" ], "type": "object" + }, + "TaskGrantStatusConfig": { + "additionalProperties": false, + "properties": { + "authority": { + "type": "string" + }, + "baseUrl": { + "type": "string" + }, + "caBundleRef": { + "type": [ + "string", + "null" + ] + }, + "caseworkResource": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "privateKeyRef": { + "type": "string" + }, + "sourceIssuer": { + "type": "string" + }, + "tokenEndpoint": { + "type": "string" + } + }, + "required": [ + "authority", + "sourceIssuer", + "baseUrl", + "tokenEndpoint", + "clientId", + "privateKeyRef", + "caseworkResource" + ], + "type": "object" } }, "$id": "https://id.registrystack.org/schemas/breg/runtime/runtime.v1alpha1.schema.json", @@ -1074,6 +1182,13 @@ }, "secretProviders": { "$ref": "#/$defs/RawSecretProvidersConfig" + }, + "taskGrantStatus": { + "default": [], + "items": { + "$ref": "#/$defs/TaskGrantStatusConfig" + }, + "type": "array" } }, "required": [ diff --git a/products/breg/immediate-actions.md b/products/breg/immediate-actions.md index e6f023320f..8f76b4e719 100644 --- a/products/breg/immediate-actions.md +++ b/products/breg/immediate-actions.md @@ -8,7 +8,7 @@ authorization, concurrency checks, idempotency, audit, events, and receipt. The fixed-effect fixtures are intentionally small: - `fixtures/asset-registration-actions` creates an asset and its initial - inspection in one action. The grant returns only the `asset` effect in the + inspection in one action. The permission returns only the `asset` effect in the action receipt, so callers do not receive the `initial-inspection` reference. - `fixtures/household-contact-actions` creates a person and membership, then patches the selected household's contact reference. It also links a service @@ -73,12 +73,12 @@ the persisted entity field at `entities[].fields`, which may still expose `assetCode`. After a module edit, run `bregctl project lock ` before repeating check, explain, generate, and the schema-test journey below. -An action grant is exclusive. It names `action`, uses only `operations: +An action permission is exclusive. It names `action`, uses only `operations: [invoke]`, and supplies target authority for every entity that the compiled effect graph creates, patches, or references: ```yaml -grants: +permissions: - action: register-household-contact operations: [invoke] targets: @@ -98,17 +98,17 @@ grants: ``` Do not add `readableFields`, `writableFields`, query fields, request-stage -fields, SQL fragments, or Rust customization to an action grant. The action +fields, SQL fragments, or Rust customization to an action permission. The action contract and typed entities define the writable ceiling, and `results` controls which minimal record and revision references appear in the application receipt. -For ordinary entity grants, keep boundary-bearing fields out of patch grants +For ordinary entity permissions, keep boundary-bearing fields out of patch permissions unless moving a row between caller-visible boundaries is intentional and reviewed. The household fixture splits setup authority from maintenance authority: `household-operator` can create required `district` values for seed households and service centers, while `household-maintainer` can patch only `household-name`. The action patch of `contact-person` is governed by the -`contact-registrar` action grant and its target row boundaries. +`contact-registrar` action permission and its target row boundaries. ## Input-only Rhai handlers @@ -243,7 +243,7 @@ points are not supported. The compiler rejects unknown inputs, fields, incompatible values, duplicate checks, and requirements above the existing 128-field or 2 MiB bounds. -The selected action grant must explicitly include the target entity and meet +The selected action permission must explicitly include the target entity and meet its mandatory scopes, purposes, and row boundaries. That grant admits the reviewed action's exact required processing, including checking a field that the caller cannot retrieve. It does not grant ordinary reads or expose the @@ -650,7 +650,7 @@ values and cannot become authenticated claims or grants. Rhai may trim and concatenate values, refuse before a call, and conditionally call another declared capability. Use `value.trim();` because Rhai trims strings in place. Declared capabilities are optional; declared local targets still require admission even -if the handler omits their write slots. The action grant covers all declared +if the handler omits their write slots. The action permission covers all declared processing, so a caller input selecting optional disclosure adds no authority. Each capability permits one call, with at most two per action. Effective defaults @@ -703,7 +703,7 @@ migration database, the command verifies the expected Registry identity, catalog and readiness while holding the Registry transaction lock through deletion. Ordinary history erasure does not cover this separate retention scope. -Eligibility remains an operation-level rule. Configure grants so CRUD, other +Eligibility remains an operation-level rule. Configure permissions so CRUD, other actions and reviewed changes cannot bypass the intended registration procedure. Native database constraints still protect local stored invariants. Synthetic `evidenceCalls` mocks verify control flow, selectors, typed results and exact diff --git a/products/breg/membership-access.md b/products/breg/membership-access.md index 8a9751a111..17ec422374 100644 --- a/products/breg/membership-access.md +++ b/products/breg/membership-access.md @@ -15,7 +15,7 @@ accessProfiles: - id: member principalClaim: principal requiredScopes: [records:read, membership:use] - grants: + permissions: - entity: facility rowBoundaries: [] operations: [get, list, lookup, snapshot, revisions] @@ -95,11 +95,11 @@ other enabled features can require a newer supported version. Membership profiles support get, list, count, lookup, snapshot, and revisions. They can protect the root of a relationship read path; a related entity's membership rules cannot silently be bypassed by targeting it through a separate -root grant. Such target grants are rejected. Membership sources must remain +root grant. Such target permissions are rejected. Membership sources must remain leaf entities without their own membership boundaries, change-request lifecycle, or incoming relationship read paths, preventing recursive row-security policies. -Spatial bbox grants use a separate authority role and cannot currently be +Spatial bbox permissions use a separate authority role and cannot currently be combined with membership boundaries. Ordinary reads of facilities remain available under the membership profile. diff --git a/products/breg/metadata.md b/products/breg/metadata.md index 5fee23f446..98fa15730a 100644 --- a/products/breg/metadata.md +++ b/products/breg/metadata.md @@ -30,7 +30,7 @@ Request lifecycle actions use `change_request_lifecycle`; a client must disable an operation when it does not understand any required capability. Unknown optional operation kinds can be ignored. Controlled entity writes remain request-only: metadata must not expose controlled target mutations as direct -create or patch grants, and a writable field list never permits a client to +create or patch permissions, and a writable field list never permits a client to bypass a required mutation or record-state capability. `fields` contains only the union of this operation's readable and applicable @@ -164,7 +164,7 @@ fields, whole-field paths, and at least one mutation remain runtime checks. and extra members are not accepted. Lookup uses `body: "selector_values"` and `contentType: "application/json"`. -`selectors` contains only that route/profile's grants: +`selectors` contains only that route/profile's permissions: ```json { @@ -271,4 +271,4 @@ capabilities. New optional descriptors remain optional when reading older servers, but a missing executable request contract returns an unsupported selection error. Older strict clients may reject added metadata members, so upgrade clients and servers together. These descriptors expose existing runtime -capabilities without changing grants, history retention, or database schema. +capabilities without changing permissions, history retention, or database schema. diff --git a/products/breg/scripts/test_validate_product.py b/products/breg/scripts/test_validate_product.py index 63c255171b..21c8b3ecb7 100644 --- a/products/breg/scripts/test_validate_product.py +++ b/products/breg/scripts/test_validate_product.py @@ -71,7 +71,7 @@ def test_security_range_is_closed_through_registry_extension_invariants(self) -> ) extension_rows = matrix["invariants"][24:] self.assertEqual( - [f"BREG-NEG-{index:02d}" for index in range(25, 64)], + [f"BREG-NEG-{index:02d}" for index in range(25, 65)], [invariant["negativeId"] for invariant in extension_rows], ) for invariant in extension_rows: diff --git a/products/breg/scripts/validate_product.py b/products/breg/scripts/validate_product.py index 49b160a156..d4010cde37 100644 --- a/products/breg/scripts/validate_product.py +++ b/products/breg/scripts/validate_product.py @@ -30,7 +30,7 @@ CONTRACT_STATES = {"enforced", "partial", "planned"} V1_REQUIREMENT_IDS = tuple(f"BREG-V1-{index:02d}" for index in range(1, 45)) ACCEPTANCE_JOURNEY_IDS = tuple(f"BREG-J{index:02d}" for index in range(1, 21)) -SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 64)) +SECURITY_INVARIANT_IDS = tuple(f"BREG-SEC-{index:02d}" for index in range(1, 65)) ACCEPTANCE_FIXTURES = { "BREG-J01": ("asset-site-placement", "acceptance/asset-site-placement"), "BREG-J02": ("asset-site-placement", "acceptance/asset-site-placement"), diff --git a/products/breg/starters/agricultural-holdings/core/MODEL.md b/products/breg/starters/agricultural-holdings/core/MODEL.md index 519d131f7c..28a2ff1025 100644 --- a/products/breg/starters/agricultural-holdings/core/MODEL.md +++ b/products/breg/starters/agricultural-holdings/core/MODEL.md @@ -2,7 +2,7 @@ This is a synthetic local learning profile. All access is authenticated and restricted to the declared profiles. Browser previews never authorize local -requests. The registry-wide grants and historical reads are explicit policy: +requests. The registry-wide permissions and historical reads are explicit policy: there are no hidden jurisdiction or ownership row filters. An institution must review these boundaries before production deployment. @@ -14,7 +14,7 @@ review these boundaries before production deployment. | Editor | Create, get, list and inspect history; PATCH only where the entity is not controlled | Create and edit own drafts; submit, revise or cancel own requests | | Reviewer | Get, list and inspect history | Read proposed fields and target fields, approve, reject, request revision and apply | -Every operation absent from the authored grants is denied, including deletion. +Every operation absent from the authored permissions is denied, including deletion. The full PATCH operation on Farm is controlled. The correction changes only `name`; it cannot change identity or holder/link endpoints. A reviewer is also the authorized applier. Review excludes the submitting principal, even diff --git a/products/breg/starters/agricultural-holdings/core/registry.yaml b/products/breg/starters/agricultural-holdings/core/registry.yaml index 323d72e57d..a8929bf54b 100644 --- a/products/breg/starters/agricultural-holdings/core/registry.yaml +++ b/products/breg/starters/agricultural-holdings/core/registry.yaml @@ -584,7 +584,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "farm", "operations": [ @@ -609,7 +609,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "farm", "operations": [ @@ -798,7 +798,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "farm", "operations": [ diff --git a/products/breg/starters/professional-licences/core/MODEL.md b/products/breg/starters/professional-licences/core/MODEL.md index 7e7d2ac0a6..9362f6a82a 100644 --- a/products/breg/starters/professional-licences/core/MODEL.md +++ b/products/breg/starters/professional-licences/core/MODEL.md @@ -74,7 +74,7 @@ linkable. Reader has no history grant. Editor/reviewer history includes old readable values. The Manifest projection describes the editor's model and grants no runtime access. -Reader, editor and reviewer retain explicit registry-wide learning grants. +Reader, editor and reviewer retain explicit registry-wide learning permissions. Holder reads require the verified scalar `person_reference` claim to equal the licence `person-reference` field. Holder and editor request lists use owner visibility; reviewer lists expose the authorized queue with native state filters. @@ -104,7 +104,7 @@ external URI strings remain unchanged and trigger no lookup. The normal and security journey suites exercise independent creation, submission, separate approval and application, rejection, unchanged unrelated fields, local vocabulary refusal and date validation. The maintained real-router -starter test additionally checks missing grants, direct mutation refusal, +starter test additionally checks missing permissions, direct mutation refusal, reader minimization and same-principal review refusal. Compiler checks establish authoring validity; only an executed PostgreSQL test proves runtime behavior. diff --git a/products/breg/starters/professional-licences/core/registry.yaml b/products/breg/starters/professional-licences/core/registry.yaml index f500f17bce..fe4074963a 100644 --- a/products/breg/starters/professional-licences/core/registry.yaml +++ b/products/breg/starters/professional-licences/core/registry.yaml @@ -349,7 +349,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "professional-license", "operations": [ @@ -377,7 +377,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "professional-license", "operations": [ @@ -456,7 +456,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "professional-license", "operations": [ @@ -545,7 +545,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "professional-license", "operations": [ diff --git a/products/breg/starters/public-organizations/core/MODEL.md b/products/breg/starters/public-organizations/core/MODEL.md index f8ae2f10af..9cc18ca544 100644 --- a/products/breg/starters/public-organizations/core/MODEL.md +++ b/products/breg/starters/public-organizations/core/MODEL.md @@ -2,7 +2,7 @@ This is a synthetic local learning profile. All access is authenticated and restricted to the declared profiles. Browser previews never authorize local -requests. The registry-wide grants and historical reads are explicit policy: +requests. The registry-wide permissions and historical reads are explicit policy: there are no hidden jurisdiction or ownership row filters. An institution must review these boundaries before production deployment. @@ -14,7 +14,7 @@ review these boundaries before production deployment. | Editor | Create, get, list and inspect history; PATCH only where the entity is not controlled | Create and edit own drafts; submit, revise or cancel own requests | | Reviewer | Get, list and inspect history | Read proposed fields and target fields, approve, reject, request revision and apply | -Every operation absent from the authored grants is denied, including deletion. +Every operation absent from the authored permissions is denied, including deletion. The full PATCH operation on PublicOrganization and InstitutionalRelationship is controlled. The correction changes only `name`; it cannot change identity or holder/link endpoints. A reviewer is also the authorized applier. Review excludes the submitting principal, even diff --git a/products/breg/starters/public-organizations/core/registry.yaml b/products/breg/starters/public-organizations/core/registry.yaml index 3c0c44fa27..973db44490 100644 --- a/products/breg/starters/public-organizations/core/registry.yaml +++ b/products/breg/starters/public-organizations/core/registry.yaml @@ -413,7 +413,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "public-organization", "operations": [ @@ -454,7 +454,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "public-organization", "operations": [ @@ -569,7 +569,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "public-organization", "operations": [ diff --git a/products/breg/starters/seed-lots/core/registry.yaml b/products/breg/starters/seed-lots/core/registry.yaml index 7133844193..7860dac63a 100644 --- a/products/breg/starters/seed-lots/core/registry.yaml +++ b/products/breg/starters/seed-lots/core/registry.yaml @@ -310,7 +310,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "seed-lot", "operations": [ @@ -351,7 +351,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "seed-lot", "operations": [ @@ -436,7 +436,7 @@ "requiredPurposes": [ "starter-learning" ], - "grants": [ + "permissions": [ { "entity": "seed-lot", "operations": [ From d5b69da85d11d73c132d2409cdbe52096c5301b2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:51 +0700 Subject: [PATCH 005/120] feat(casework): checkpoint task grants and client contracts Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- crates/registry-casework-breg/src/lib.rs | 59 + .../registry-casework-client-node/README.md | 8 + .../__test__/facade.test-d.ts | 15 + .../__test__/task-grants.test.js | 36 + .../registry-casework-client-node/client.d.ts | 29 + .../registry-casework-client-node/client.js | 6 + .../registry-casework-client-node/index.d.ts | 6 + .../registry-casework-client-node/src/lib.rs | 97 + crates/registry-casework-client-py/README.md | 8 + .../registry_casework_client/__init__.pyi | 69 + crates/registry-casework-client-py/src/lib.rs | 127 + .../tests/python/test_task_grants.py | 64 + crates/registry-casework-client/src/client.rs | 101 + crates/registry-casework-client/src/lib.rs | 7 +- crates/registry-casework-core/src/adapter.rs | 12 + crates/registry-casework-core/src/config.rs | 20 + crates/registry-casework-core/src/lib.rs | 2 + crates/registry-casework-core/src/model.rs | 3 + .../registry-casework-core/src/task_grant.rs | 373 + crates/registry-casework/Cargo.toml | 10 + .../migrations/0014_task_grants.sql | 105 + crates/registry-casework/src/auth.rs | 36 + crates/registry-casework/src/config.rs | 45 +- crates/registry-casework/src/http.rs | 137 + crates/registry-casework/src/lib.rs | 1 + crates/registry-casework/src/problem.rs | 102 + crates/registry-casework/src/runtime.rs | 93 +- crates/registry-casework/src/service.rs | 2 + crates/registry-casework/src/store.rs | 9 +- crates/registry-casework/src/task_grants.rs | 721 ++ .../src/task_grants/http_tests.rs | 570 ++ .../src/task_grants/native_exchange_tests.rs | 109 + .../src/task_grants/native_resource.rs | 390 + .../src/task_grants/tests.rs | 81 + .../tests/assignment_postgres.rs | 1 + .../tests/hosted_postgres.rs | 1 + .../tests/human_identity_auth.rs | 1 + .../tests/inbox_ordering_postgres.rs | 1 + .../tests/routing_postgres.rs | 1 + .../tests/service_visibility.rs | 1 + .../tests/source_retention_postgres.rs | 1 + .../tests/task_grant_invalidation.rs | 147 + crates/registry-caseworkctl/Cargo.toml | 3 + crates/registry-caseworkctl/src/dev/config.rs | 166 +- crates/registry-caseworkctl/src/dev/mod.rs | 796 +- crates/registry-caseworkctl/src/dev/tests.rs | 54 +- crates/registry-caseworkctl/src/lib.rs | 5 + .../breg/client.d.ts | 16 + .../casework/client.d.ts | 29 + .../casework/client.js | 6 + .../casework/index.d.ts | 6 + .../evidence/client.d.ts | 16 + .../relay/client.d.ts | 16 + products/casework/README.md | 1 + products/casework/TASK_GRANTS.md | 84 + .../generated/registry-casework.openapi.json | 7755 +++++++++++------ products/casework/scripts/generate_openapi.py | 74 +- 57 files changed, 9203 insertions(+), 3431 deletions(-) create mode 100644 crates/registry-casework-client-node/__test__/task-grants.test.js create mode 100644 crates/registry-casework-client-py/tests/python/test_task_grants.py create mode 100644 crates/registry-casework-core/src/task_grant.rs create mode 100644 crates/registry-casework/migrations/0014_task_grants.sql create mode 100644 crates/registry-casework/src/task_grants.rs create mode 100644 crates/registry-casework/src/task_grants/http_tests.rs create mode 100644 crates/registry-casework/src/task_grants/native_exchange_tests.rs create mode 100644 crates/registry-casework/src/task_grants/native_resource.rs create mode 100644 crates/registry-casework/src/task_grants/tests.rs create mode 100644 crates/registry-casework/tests/task_grant_invalidation.rs create mode 100644 products/casework/TASK_GRANTS.md diff --git a/crates/registry-casework-breg/src/lib.rs b/crates/registry-casework-breg/src/lib.rs index 65f79739f9..1dbf380ba4 100644 --- a/crates/registry-casework-breg/src/lib.rs +++ b/crates/registry-casework-breg/src/lib.rs @@ -819,6 +819,65 @@ impl SourceAdapter for BregAdapter { }) } + async fn read_task_context( + &self, + subject: &SubjectRef, + fields: &[String], + caller: Option<(&str, EphemeralCredential<'_>)>, + ) -> Result { + if fields.is_empty() + || fields.len() > 32 + || fields.iter().any(|field| { + !self + .config + .routing_metadata + .fields + .iter() + .any(|descriptor| descriptor.field == *field) + }) + { + return Err(SourceAdapterError::Denied); + } + let record = if let Some((profile, credential)) = caller { + let client = self.caller(credential)?; + self.read(&client, subject, profile).await?.0 + } else { + self.read(&self.reader, subject, &self.config.reader_profile) + .await? + .0 + }; + let request = Self::request(&record)?; + if matches!( + request.breg_state(), + BRegRequestState::Draft + | BRegRequestState::Rejected + | BRegRequestState::Applied + | BRegRequestState::Canceled + ) { + return Err(SourceAdapterError::Denied); + } + let mut values = BTreeMap::new(); + for field in fields { + let descriptor = self + .config + .routing_metadata + .fields + .iter() + .find(|descriptor| descriptor.field == *field) + .ok_or(SourceAdapterError::Denied)?; + let value = record + .data + .domain_data + .get(&descriptor.api_name) + .ok_or(SourceAdapterError::Denied)?; + values.insert(field.clone(), value.clone()); + } + Ok(TaskSubjectContext { + binding: self.binding(&record, &request)?, + values, + }) + } + async fn read_for_caller( &self, subject: &SubjectRef, diff --git a/crates/registry-casework-client-node/README.md b/crates/registry-casework-client-node/README.md index 64285a2b4a..8c7344b565 100644 --- a/crates/registry-casework-client-node/README.md +++ b/crates/registry-casework-client-node/README.md @@ -6,3 +6,11 @@ This internal napi-rs binding supplies the `casework` module assembled into Every operation accepts a bearer token and an explicit Casework profile for that call. Source-reading operations also require an explicit source profile. The binding does not retain credentials and exposes no browser credential API. + +Task delegation uses the current human profile for template previews, grant +approval, listing, and revocation. Approval accepts only the template ID and +version, with the item revision and a caller-owned idempotency key. The preview +contains the exact destination, purpose, authority bounds, derived subjects, +and lifetime; the grant list omits stored subjects. Agent assertion and grant +status calls take only a bearer token and grant ID, without human or source +profile headers. Neither binding retains credentials or retries a mutation. diff --git a/crates/registry-casework-client-node/__test__/facade.test-d.ts b/crates/registry-casework-client-node/__test__/facade.test-d.ts index 2c5c8128dc..477050281f 100644 --- a/crates/registry-casework-client-node/__test__/facade.test-d.ts +++ b/crates/registry-casework-client-node/__test__/facade.test-d.ts @@ -140,3 +140,18 @@ void client.previewClockRecompute(token, 'administrator', { clockId: 'review-dea void client.applyClockRecompute(token, 'administrator', 'apply-preview', { previewId: item.itemId }) void client.updateDirectoryTeam(token, 'administrator', 'review-team', 4, 'team-update', { staff: [namedOfficer], supervisors: [cover], servedQueues: ['review'] }) + +void client.previewTaskTemplates(token, profile, sourceProfile, item.itemId).then(result => { + const revision: number = result.value.itemRevision + const lifetime: number | undefined = result.value.templates[0]?.lifetimeSeconds + void revision; void lifetime +}) +void client.listTaskGrants(token, profile, sourceProfile, item.itemId) +void client.approveTaskGrant(token, profile, sourceProfile, item.itemId, 7, 'task-key', { templateId: 'verify-status', templateVersion: '1' }) +void client.revokeTaskGrant(token, profile, sourceProfile, item.itemId, item.itemId) +void client.taskAssertion(token, item.itemId) +void client.taskGrantStatus(token, item.itemId) +// @ts-expect-error Policy bounds must come from the governed template. +void client.approveTaskGrant(token, profile, sourceProfile, item.itemId, 7, 'task-key', { templateId: 'verify-status', templateVersion: '1', resource: 'urn:forged' }) +// @ts-expect-error Agent assertion calls do not accept human profiles. +void client.taskAssertion(token, profile, sourceProfile, item.itemId) diff --git a/crates/registry-casework-client-node/__test__/task-grants.test.js b/crates/registry-casework-client-node/__test__/task-grants.test.js new file mode 100644 index 0000000000..c2c652951a --- /dev/null +++ b/crates/registry-casework-client-node/__test__/task-grants.test.js @@ -0,0 +1,36 @@ +'use strict'; +const test=require('node:test'); +const assert=require('node:assert/strict'); +const http=require('node:http'); +const {CaseworkClient}=require('../client'); +const item='00000000-0000-4000-8000-000000000001'; +const grant='00000000-0000-4000-8000-000000000002'; +const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; +const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; +test('task grants preserve preview, approval revision/key and token-only machine calls',async(t)=>{ + const requests=[]; + const server=http.createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{requests.push({path:req.url,method:req.method,headers:req.headers,body});let value; + if(req.url.endsWith('/task-templates'))value={itemRevision:7,templates:[preview]}; + else if(req.url.endsWith('/assertion'))value={assertion:'synthetic-assertion',expiresAt:2000000060,grantExpiresAt:2000000900}; + else if(req.url.endsWith('/status'))value={active:false}; + else if(req.url.endsWith('/revoke'))value={id:grant,invalidated:true}; + else value=req.method==='POST'?view:{grants:[view]}; + res.writeHead(200,{'content-type':'application/json',traceparent:'00-0123456789abcdef0123456789abcdef-0123456789abcdef-01'});res.end(JSON.stringify(value));});}); + await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r))); + const client=new CaseworkClient({baseUrl:`http://127.0.0.1:${server.address().port}/`}); + assert.deepEqual((await client.previewTaskTemplates('human-token','staff','source-reviewer',item)).value.templates,[preview]); + assert.equal((await client.listTaskGrants('human-token','staff','source-reviewer',item)).value.grants[0].id,grant); + const approval={templateId:'verify-status',templateVersion:'1'}; + await client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',approval); + assert.deepEqual(JSON.parse(requests[2].body),approval);assert.equal(requests[2].headers['if-match'],'"7"');assert.equal(requests[2].headers['idempotency-key'],'caller-attempt-key'); + await client.revokeTaskGrant('human-token','staff','source-reviewer',item,grant); + assert.equal(requests[3].body,''); + assert.equal((await client.taskAssertion('bootstrap-token',grant)).value.assertion,'synthetic-assertion'); + assert.deepEqual((await client.taskGrantStatus('resource-token',grant)).value,{active:false}); + for(const request of requests.slice(0,4)){assert.equal(request.headers['registry-casework-profile'],'staff');assert.equal(request.headers['registry-source-profile'],'source-reviewer');} + for(const request of requests.slice(4)){assert.equal(request.headers['registry-casework-profile'],undefined);assert.equal(request.headers['registry-source-profile'],undefined);} + const before=requests.length; + await assert.rejects(client.approveTaskGrant('human-token','staff','source-reviewer',item,7,'caller-attempt-key',{...approval,resource:'urn:other'}),e=>e.kind==='invalid_request'); + await assert.rejects(client.taskAssertion('bootstrap-token','not-a-grant-id'),e=>e.kind==='invalid_request'); + assert.equal(requests.length,before); +}); diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 039592f111..60e7999040 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -21,6 +21,29 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface TaskApprovalRequest { templateId: string; templateVersion: string } +export interface TaskTemplatePreview { + id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger +} +export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } +/** Grant metadata deliberately excludes stored subject values. */ +export interface TaskGrantView { + id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean +} +export interface TaskGrantList { grants: ReadonlyArray } +export interface TaskGrantRevocation { id: string; invalidated: boolean } +/** A short-lived credential. Do not persist or log the assertion. */ +export interface TaskAssertionResponse { assertion: string; expiresAt: SafeInteger; grantExpiresAt: SafeInteger } +export interface TaskGrantStatusDetails { + grantId: string; authority: string; sourceIssuer: string; principal: string; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; expiresAt: SafeInteger +} +export interface TaskGrantStatus { active: boolean; grant?: TaskGrantStatusDetails } + export interface DirectoryMember { issuer: string; subject: string; displayName?: string | null } export interface SubjectRef { sourceId: string; kind: string; id: string } export interface SourceBinding { @@ -411,6 +434,12 @@ export class CaseworkClient { listWorkItems(token: string, profile: string, sourceProfile: string, query: ListWorkItemsQuery): Promise> nextWorkItem(token: string, profile: string, sourceProfile: string, query?: NextWorkItemQuery | null): Promise> getWorkItem(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + previewTaskTemplates(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + listTaskGrants(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + approveTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, expectedRevision: SafeInteger, idempotencyKey: string, approval: TaskApprovalRequest): Promise> + revokeTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, grantId: string): Promise> + taskAssertion(token: string, grantId: string): Promise> + taskGrantStatus(token: string, grantId: string): Promise> claimWorkItem(token: string, profile: string, sourceProfile: string, action: CaseworkAction, idempotencyKey: string): Promise> releaseWorkItem(token: string, profile: string, sourceProfile: string, action: CaseworkAction, idempotencyKey: string): Promise> getDraft(token: string, profile: string, sourceProfile: string, itemId: string): Promise> diff --git a/crates/registry-casework-client-node/client.js b/crates/registry-casework-client-node/client.js index 1a713ff7cb..9b5814321e 100644 --- a/crates/registry-casework-client-node/client.js +++ b/crates/registry-casework-client-node/client.js @@ -112,6 +112,12 @@ for (const [method, jsonIndexes] of [ ['listWorkItems', [3]], ['nextWorkItem', [3]], ['getWorkItem', []], + ['previewTaskTemplates', []], + ['listTaskGrants', []], + ['approveTaskGrant', [6]], + ['revokeTaskGrant', []], + ['taskAssertion', []], + ['taskGrantStatus', []], ['claimWorkItem', [3]], ['releaseWorkItem', [3]], ['getDraft', []], diff --git a/crates/registry-casework-client-node/index.d.ts b/crates/registry-casework-client-node/index.d.ts index 3b55a55a3a..8da15309b5 100644 --- a/crates/registry-casework-client-node/index.d.ts +++ b/crates/registry-casework-client-node/index.d.ts @@ -19,6 +19,12 @@ export declare class CaseworkClient { listWorkItems(token: string, profile: string, sourceProfile: string, query: any): Promise nextWorkItem(token: string, profile: string, sourceProfile: string, query?: any | undefined | null): Promise getWorkItem(token: string, profile: string, sourceProfile: string, itemId: string): Promise + previewTaskTemplates(token: string, profile: string, sourceProfile: string, itemId: string): Promise + listTaskGrants(token: string, profile: string, sourceProfile: string, itemId: string): Promise + approveTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, expectedRevision: number, idempotencyKey: string, approval: any): Promise + revokeTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, grantId: string): Promise + taskAssertion(token: string, grantId: string): Promise + taskGrantStatus(token: string, grantId: string): Promise claimWorkItem(token: string, profile: string, sourceProfile: string, action: any, idempotencyKey: string): Promise releaseWorkItem(token: string, profile: string, sourceProfile: string, action: any, idempotencyKey: string): Promise getDraft(token: string, profile: string, sourceProfile: string, itemId: string): Promise diff --git a/crates/registry-casework-client-node/src/lib.rs b/crates/registry-casework-client-node/src/lib.rs index 7c70fb3309..8cf6cc10cb 100644 --- a/crates/registry-casework-client-node/src/lib.rs +++ b/crates/registry-casework-client-node/src/lib.rs @@ -395,6 +395,103 @@ impl CaseworkClient { ) } + #[napi] + pub async fn preview_task_templates( + &self, + token: String, + profile: String, + source_profile: String, + item_id: String, + ) -> Result { + let item_id = uuid(&item_id)?; + let token = bearer(token)?; + outcome( + self.inner + .preview_task_templates(auth(&token, &profile, &source_profile), item_id) + .await, + ) + } + + #[napi] + pub async fn list_task_grants( + &self, + token: String, + profile: String, + source_profile: String, + item_id: String, + ) -> Result { + let item_id = uuid(&item_id)?; + let token = bearer(token)?; + outcome( + self.inner + .list_task_grants(auth(&token, &profile, &source_profile), item_id) + .await, + ) + } + + #[napi] + #[allow(clippy::too_many_arguments)] + pub async fn approve_task_grant( + &self, + token: String, + profile: String, + source_profile: String, + item_id: String, + expected_revision: i64, + idempotency_key: String, + approval: Value, + ) -> Result { + safe_revision(expected_revision)?; + let item_id = uuid(&item_id)?; + let approval: registry_casework_client::TaskApprovalRequest = input(approval)?; + let token = bearer(token)?; + outcome( + self.inner + .approve_task_grant( + auth(&token, &profile, &source_profile), + item_id, + expected_revision, + &idempotency_key, + &approval, + ) + .await, + ) + } + #[napi] + pub async fn revoke_task_grant( + &self, + token: String, + profile: String, + source_profile: String, + item_id: String, + grant_id: String, + ) -> Result { + let item_id = uuid(&item_id)?; + let grant_id = uuid(&grant_id)?; + let token = bearer(token)?; + outcome( + self.inner + .revoke_task_grant(auth(&token, &profile, &source_profile), item_id, grant_id) + .await, + ) + } + #[napi] + pub async fn task_assertion(&self, token: String, grant_id: String) -> Result { + let grant_id = uuid(&grant_id)?; + let token = bearer(token)?; + outcome(self.inner.task_assertion(&token, grant_id).await) + } + #[napi] + pub async fn task_grant_status( + &self, + token: String, + grant_id: String, + ) -> Result { + let grant_id = uuid(&grant_id)?; + let token = bearer(token)?; + outcome(self.inner.task_grant_status(&token, grant_id).await) + } + #[napi] pub async fn claim_work_item( &self, diff --git a/crates/registry-casework-client-py/README.md b/crates/registry-casework-client-py/README.md index 6b0f7cc784..ea33ca20bf 100644 --- a/crates/registry-casework-client-py/README.md +++ b/crates/registry-casework-client-py/README.md @@ -31,3 +31,11 @@ details, and original attempt identifiers. Callers can therefore handle cursor or idempotency expiry explicitly; the binding does not retry or replace a key. This crate is private and does not publish a standalone Python distribution. + +Task delegation uses the current human profile for template previews, grant +approval, listing, and revocation. Approval accepts only the template ID and +version, with the item revision and a caller-owned idempotency key. The preview +contains the exact destination, purpose, authority bounds, derived subjects, +and lifetime; the grant list omits stored subjects. Agent assertion and grant +status calls take only a bearer token and grant ID, without human or source +profile headers. Neither binding retains credentials or retries a mutation. diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 2419584e57..aef0da0401 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -22,6 +22,69 @@ class IssuerPrincipal(TypedDict): issuer: str subject: str +class TaskPermission(TypedDict): + collection: str + operations: list[str] +class EvidenceTaskBounds(TypedDict): + type: Literal["evidence"] + requirement: str +class BregTaskBounds(TypedDict): + type: Literal["breg"] + permissions: list[TaskPermission] +TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds +class TaskApprovalRequest(TypedDict): + templateId: str + templateVersion: str +class TaskTemplatePreview(TypedDict): + id: str + version: str + label: str + agent: IssuerPrincipal + client: str + resource: str + purpose: str + bounds: TaskGrantBounds + subjects: dict[str, str | int | bool] + lifetimeSeconds: int +class TaskTemplatePreviews(TypedDict): + itemRevision: int + templates: list[TaskTemplatePreview] +class TaskGrantView(TypedDict): + id: str + templateId: str + templateVersion: str + agent: IssuerPrincipal + client: str + resource: str + purpose: str + bounds: TaskGrantBounds + expiresAt: int + invalidated: bool +class TaskGrantList(TypedDict): + grants: list[TaskGrantView] +class TaskGrantRevocation(TypedDict): + id: str + invalidated: bool +class TaskAssertionResponse(TypedDict): + assertion: str + expiresAt: int + grantExpiresAt: int +class TaskGrantStatusDetails(TypedDict): + grantId: str + authority: str + sourceIssuer: str + principal: str + client: str + resource: str + purpose: str + bounds: TaskGrantBounds + subjects: dict[str, str | int | bool] + expiresAt: int +class _TaskGrantStatusOptional(TypedDict, total=False): + grant: TaskGrantStatusDetails +class TaskGrantStatus(_TaskGrantStatusOptional): + active: bool + class _DirectoryMemberOptional(TypedDict, total=False): displayName: str | None @@ -740,6 +803,12 @@ class CaseworkClient: def list_work_items(self, token: str, profile: str, source_profile: str, query: ListWorkItemsQuery) -> Complete[WorkItemPage]: ... def next_work_item(self, token: str, profile: str, source_profile: str, query: NextWorkItemQuery | None = None) -> Complete[WorkItemPage]: ... def get_work_item(self, token: str, profile: str, source_profile: str, item_id: str) -> Complete[WorkItem]: ... + def preview_task_templates(self, token: str, profile: str, source_profile: str, item_id: str) -> Complete[TaskTemplatePreviews]: ... + def list_task_grants(self, token: str, profile: str, source_profile: str, item_id: str) -> Complete[TaskGrantList]: ... + def approve_task_grant(self, token: str, profile: str, source_profile: str, item_id: str, expected_revision: int, idempotency_key: str, approval: TaskApprovalRequest) -> Complete[TaskGrantView]: ... + def revoke_task_grant(self, token: str, profile: str, source_profile: str, item_id: str, grant_id: str) -> Complete[TaskGrantRevocation]: ... + def task_assertion(self, token: str, grant_id: str) -> Complete[TaskAssertionResponse]: ... + def task_grant_status(self, token: str, grant_id: str) -> Complete[TaskGrantStatus]: ... def claim_work_item(self, token: str, profile: str, source_profile: str, action: CaseworkAction, idempotency_key: str) -> Complete[MutationResponse]: ... def release_work_item(self, token: str, profile: str, source_profile: str, action: CaseworkAction, idempotency_key: str) -> Complete[MutationResponse]: ... def get_draft(self, token: str, profile: str, source_profile: str, item_id: str) -> Complete[DraftResponse]: ... diff --git a/crates/registry-casework-client-py/src/lib.rs b/crates/registry-casework-client-py/src/lib.rs index a79f3ce76d..a56a312e0a 100644 --- a/crates/registry-casework-client-py/src/lib.rs +++ b/crates/registry-casework-client-py/src/lib.rs @@ -718,6 +718,133 @@ impl CaseworkClient { ) } + fn preview_task_templates<'py>( + &self, + py: Python<'py>, + token: &str, + profile: &str, + source_profile: &str, + item_id: &str, + ) -> PyResult> { + let item_id = uuid(py, item_id)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime.block_on( + self.inner.preview_task_templates( + auth(&token, profile, Some(source_profile)), + item_id, + ), + ) + }), + ) + } + fn list_task_grants<'py>( + &self, + py: Python<'py>, + token: &str, + profile: &str, + source_profile: &str, + item_id: &str, + ) -> PyResult> { + let item_id = uuid(py, item_id)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime.block_on( + self.inner + .list_task_grants(auth(&token, profile, Some(source_profile)), item_id), + ) + }), + ) + } + #[allow(clippy::too_many_arguments)] + fn approve_task_grant<'py>( + &self, + py: Python<'py>, + token: &str, + profile: &str, + source_profile: &str, + item_id: &str, + expected_revision: i64, + idempotency_key: &str, + approval: &Bound<'_, PyAny>, + ) -> PyResult> { + let item_id = uuid(py, item_id)?; + let approval: casework_client_sdk::TaskApprovalRequest = input(py, approval)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime.block_on(self.inner.approve_task_grant( + auth(&token, profile, Some(source_profile)), + item_id, + expected_revision, + idempotency_key, + &approval, + )) + }), + ) + } + #[allow(clippy::too_many_arguments)] + fn revoke_task_grant<'py>( + &self, + py: Python<'py>, + token: &str, + profile: &str, + source_profile: &str, + item_id: &str, + grant_id: &str, + ) -> PyResult> { + let item_id = uuid(py, item_id)?; + let grant_id = uuid(py, grant_id)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime.block_on(self.inner.revoke_task_grant( + auth(&token, profile, Some(source_profile)), + item_id, + grant_id, + )) + }), + ) + } + fn task_assertion<'py>( + &self, + py: Python<'py>, + token: &str, + grant_id: &str, + ) -> PyResult> { + let grant_id = uuid(py, grant_id)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime + .block_on(self.inner.task_assertion(&token, grant_id)) + }), + ) + } + fn task_grant_status<'py>( + &self, + py: Python<'py>, + token: &str, + grant_id: &str, + ) -> PyResult> { + let grant_id = uuid(py, grant_id)?; + let token = bearer(py, token)?; + complete( + py, + py.detach(|| { + self.runtime + .block_on(self.inner.task_grant_status(&token, grant_id)) + }), + ) + } + #[allow(clippy::too_many_arguments)] fn claim_work_item<'py>( &self, diff --git a/crates/registry-casework-client-py/tests/python/test_task_grants.py b/crates/registry-casework-client-py/tests/python/test_task_grants.py new file mode 100644 index 0000000000..aef4e1e64d --- /dev/null +++ b/crates/registry-casework-client-py/tests/python/test_task_grants.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from bootstrap import ensure_built + +ensure_built() +from registry_casework_client import CaseworkClient, CaseworkClientError + +ITEM = "00000000-0000-4000-8000-000000000001" +GRANT = "00000000-0000-4000-8000-000000000002" +PREVIEW = {"id": "verify-status", "version": "1", "label": "Verify status", "agent": {"issuer": "https://issuer.example", "subject": "agent-one"}, "client": "agent-client", "resource": "urn:evidence", "purpose": "verify-status", "bounds": {"type": "evidence", "requirement": "status"}, "subjects": {"person_reference": "synthetic-reference"}, "lifetimeSeconds": 900} +VIEW = {"id": GRANT, "templateId": PREVIEW["id"], "templateVersion": "1", **{k: PREVIEW[k] for k in ("agent", "client", "resource", "purpose", "bounds")}, "expiresAt": 2000000900, "invalidated": False} + +class TaskGrantTests(unittest.TestCase): + def test_bounded_human_approval_and_token_only_machine_requests(self): + requests = [] + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_GET(self): self.respond() + def do_POST(self): self.respond() + def respond(self): + body = self.rfile.read(int(self.headers.get("content-length", "0"))) + requests.append((self.path, dict(self.headers), body)) + if self.path.endswith("/task-templates"): value = {"itemRevision": 7, "templates": [PREVIEW]} + elif self.path.endswith("/assertion"): value = {"assertion": "synthetic-assertion", "expiresAt": 2000000060, "grantExpiresAt": 2000000900} + elif self.path.endswith("/status"): value = {"active": False} + elif self.path.endswith("/revoke"): value = {"id": GRANT, "invalidated": True} + else: value = VIEW if self.command == "POST" else {"grants": [VIEW]} + self.send_response(200); self.send_header("content-type", "application/json"); self.send_header("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"); self.end_headers() + self.wfile.write(json.dumps(value).encode()) + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True); thread.start() + try: + client = CaseworkClient(f"http://127.0.0.1:{server.server_port}/") + args = ("human-token", "staff", "source-reviewer", ITEM) + self.assertEqual(client.preview_task_templates(*args)["value"]["templates"], [PREVIEW]) + self.assertEqual(client.list_task_grants(*args)["value"]["grants"], [VIEW]) + approval = {"templateId": "verify-status", "templateVersion": "1"} + client.approve_task_grant(*args, 7, "caller-attempt-key", approval) + headers = {k.lower(): v for k, v in requests[2][1].items()} + self.assertEqual(headers["if-match"], '\"7\"') + self.assertEqual(headers["idempotency-key"], "caller-attempt-key") + self.assertEqual(json.loads(requests[2][2]), approval) + client.revoke_task_grant(*args, GRANT) + self.assertEqual(requests[3][2], b"") + self.assertEqual(client.task_assertion("bootstrap-token", GRANT)["value"]["assertion"], "synthetic-assertion") + self.assertEqual(client.task_grant_status("resource-token", GRANT)["value"], {"active": False}) + for _, headers, _ in requests[:4]: + headers = {k.lower(): v for k, v in headers.items()} + self.assertEqual(headers["registry-casework-profile"], "staff") + self.assertEqual(headers["registry-source-profile"], "source-reviewer") + for _, headers, _ in requests[4:]: + headers = {k.lower(): v for k, v in headers.items()} + self.assertNotIn("registry-casework-profile", headers) + self.assertNotIn("registry-source-profile", headers) + count = len(requests) + with self.assertRaises(CaseworkClientError): client.approve_task_grant(*args, 7, "caller-attempt-key", {**approval, "resource": "urn:other"}) + with self.assertRaises(CaseworkClientError): client.task_assertion("bootstrap-token", "invalid") + self.assertEqual(len(requests), count) + finally: + server.shutdown(); thread.join(); server.server_close() diff --git a/crates/registry-casework-client/src/client.rs b/crates/registry-casework-client/src/client.rs index 45d0ed2a64..91ce178eaa 100644 --- a/crates/registry-casework-client/src/client.rs +++ b/crates/registry-casework-client/src/client.rs @@ -342,6 +342,107 @@ impl CaseworkClient { .await } + pub async fn preview_task_templates( + &self, + auth: CaseworkAuth<'_>, + item_id: Uuid, + ) -> Result, CaseworkClientError> + { + require_source_profile(&auth)?; + self.get_json( + &auth, + &["v1", "work-items", &item_id.to_string(), "task-templates"], + &[], + ) + .await + } + + pub async fn list_task_grants( + &self, + auth: CaseworkAuth<'_>, + item_id: Uuid, + ) -> Result, CaseworkClientError> { + require_source_profile(&auth)?; + self.get_json( + &auth, + &["v1", "work-items", &item_id.to_string(), "task-grants"], + &[], + ) + .await + } + + pub async fn approve_task_grant( + &self, + auth: CaseworkAuth<'_>, + item_id: Uuid, + expected_revision: i64, + idempotency_key: &str, + approval: ®istry_casework_core::TaskApprovalRequest, + ) -> Result, CaseworkClientError> { + require_source_profile(&auth)?; + self.mutate( + &auth, + &["v1", "work-items", &item_id.to_string(), "task-grants"], + expected_revision, + idempotency_key, + approval, + ) + .await + } + + pub async fn revoke_task_grant( + &self, + auth: CaseworkAuth<'_>, + item_id: Uuid, + grant_id: Uuid, + ) -> Result, CaseworkClientError> + { + require_source_profile(&auth)?; + let url = self.url(&[ + "v1", + "work-items", + &item_id.to_string(), + "task-grants", + &grant_id.to_string(), + "revoke", + ])?; + self.send_json(self.authorized(self.http.post(url), &auth)?, StatusCode::OK) + .await + } + + /// Obtain an assertion using this agent's short-lived bootstrap token. + /// No human or source profile header is sent and no credential is retained. + pub async fn task_assertion( + &self, + token: &crate::BearerToken, + grant_id: Uuid, + ) -> Result, CaseworkClientError> + { + let url = self.url(&["v1", "task-grants", &grant_id.to_string(), "assertion"])?; + let request = self + .http + .post(url) + .header(AUTHORIZATION, token.authorization_header_value()) + .header(ACCEPT, JSON_MEDIA_TYPE); + self.send_json(request, StatusCode::OK).await + } + + /// Perform a fresh check using the service token registered for the resource. + pub async fn task_grant_status( + &self, + token: &crate::BearerToken, + grant_id: Uuid, + ) -> Result, CaseworkClientError> + { + let url = self.url(&["v1", "task-grants", &grant_id.to_string(), "status"])?; + let request = self + .http + .get(url) + .header(AUTHORIZATION, token.authorization_header_value()) + .header(ACCEPT, JSON_MEDIA_TYPE); + self.send_json(request, StatusCode::OK).await + } + pub async fn claim_work_item( &self, auth: CaseworkAuth<'_>, diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index b8698fa7f6..92647e4363 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -35,8 +35,11 @@ pub use registry_casework_core::{ PageStatus, QueueRecord, RecoverAttemptRequest, ReleaseRequest, RequesterHostedItem, RoutingActivity, RoutingCondition, RoutingPredicate, RoutingRule, SaveDraftRequest, SourceBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, - SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, TeamRecord, - WorkItem, WorkItemPage, WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, + SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, TaskApprovalRequest, + TaskAssertionResponse, TaskGrantBounds, TaskGrantList, TaskGrantRevocation, TaskGrantStatus, + TaskGrantStatusDetails, TaskGrantView, TaskPermission, TaskTemplatePreview, + TaskTemplatePreviews, TeamRecord, WorkItem, WorkItemPage, WorkItemRouting, WorkingDaysAfter, + WorkingDaysBefore, WorkingWeekday, }; pub use registry_platform_httputil::client::BearerToken; /// The identifier type every item, attempt, and event argument carries, so a diff --git a/crates/registry-casework-core/src/adapter.rs b/crates/registry-casework-core/src/adapter.rs index 6be33dfbae..d4264cee67 100644 --- a/crates/registry-casework-core/src/adapter.rs +++ b/crates/registry-casework-core/src/adapter.rs @@ -246,6 +246,18 @@ pub trait SourceAdapter: Send + Sync { credential: EphemeralCredential<'_>, ) -> Result; + /// Read only governed task fields from one exact source subject. `None` + /// selects the adapter's service reader for later grant validity checks. + /// A caller read always uses that caller's ephemeral source credential. + async fn read_task_context( + &self, + _subject: &SubjectRef, + _fields: &[String], + _caller: Option<(&str, EphemeralCredential<'_>)>, + ) -> Result { + Err(SourceAdapterError::Denied) + } + /// Freshly read, compare the displayed binding, promote the native action, /// and return an inert exact-attempt capsule. This performs no source write. async fn prepare_action( diff --git a/crates/registry-casework-core/src/config.rs b/crates/registry-casework-core/src/config.rs index 5b5c500064..6e8d3b518b 100644 --- a/crates/registry-casework-core/src/config.rs +++ b/crates/registry-casework-core/src/config.rs @@ -75,6 +75,8 @@ pub struct CaseworkProject { pub clocks: Vec, #[serde(default)] pub inbox: InboxPolicy, + #[serde(default)] + pub task_templates: Vec, } impl CaseworkProject { @@ -103,6 +105,21 @@ impl CaseworkProject { if self.casework.id.is_empty() || self.casework.version.is_empty() { return Err(ConfigError::Identifier); } + if self.task_templates.len() > 64 + || self + .task_templates + .iter() + .map(|template| &template.id) + .collect::>() + .len() + != self.task_templates.len() + || self + .task_templates + .iter() + .any(|template| template.check(self).is_err()) + { + return Err(ConfigError::TaskTemplate); + } let queues: BTreeSet<_> = self.queues.iter().map(|queue| queue.id.clone()).collect(); if queues.len() != self.queues.len() || queues.is_empty() @@ -481,6 +498,8 @@ impl InboxPolicy { #[derive(Clone, Debug, Error, Eq, PartialEq)] pub enum ConfigError { + #[error("the governed task template is invalid or repeated")] + TaskTemplate, #[error("the Casework project envelope is invalid")] Envelope, #[error("a Casework identifier is invalid")] @@ -561,6 +580,7 @@ mod tests { fn project() -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: CASEWORK_API_VERSION.to_owned(), kind: CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework-core/src/lib.rs b/crates/registry-casework-core/src/lib.rs index 07818eacb2..0017ebea70 100644 --- a/crates/registry-casework-core/src/lib.rs +++ b/crates/registry-casework-core/src/lib.rs @@ -16,6 +16,7 @@ mod model; mod policy; mod routing; mod source_retention; +mod task_grant; mod timing; mod transition; @@ -31,5 +32,6 @@ pub use model::*; pub use policy::*; pub use routing::*; pub use source_retention::*; +pub use task_grant::*; pub use timing::*; pub use transition::*; diff --git a/crates/registry-casework-core/src/model.rs b/crates/registry-casework-core/src/model.rs index 69320d7f89..acbc8d6a8d 100644 --- a/crates/registry-casework-core/src/model.rs +++ b/crates/registry-casework-core/src/model.rs @@ -407,6 +407,9 @@ pub enum HistoryKind { CaseloadMoved, Released, DraftSaved, + TaskApproved, + TaskRevoked, + TaskInvalidated, AttemptReserved, AttemptUncertain, ActionCompleted, diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs new file mode 100644 index 0000000000..6ac3b5b817 --- /dev/null +++ b/crates/registry-casework-core/src/task_grant.rs @@ -0,0 +1,373 @@ +//! Governed task delegation policy and immutable source-bound authorization. +use crate::{ + CaseworkProject, CaseworkRole, IssuerPrincipal, OccurrenceState, SourceBinding, SubjectRef, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; +use thiserror::Error; +use uuid::Uuid; + +pub const TASK_GRANT_LIFETIME_SECONDS: u64 = 900; +pub const TASK_ASSERTION_LIFETIME_SECONDS: u64 = 60; + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskTemplate { + pub id: String, + pub version: String, + pub label: String, + pub eligible_teams: Vec, + pub eligible_profiles: Vec, + pub source: String, + pub item_kinds: Vec, + pub item_states: Vec, + pub agent: IssuerPrincipal, + pub client: String, + pub resource: String, + pub purpose: String, + pub bounds: TaskGrantBounds, + /// Exact token identity keys mapped to governed source logical fields. + /// Values are extracted from the approving caller's disclosed source read. + pub subjects: BTreeMap, + pub lifetime_seconds: u64, +} + +impl fmt::Debug for TaskTemplate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TaskTemplate") + .field("id", &self.id) + .field("version", &self.version) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum TaskGrantBounds { + Evidence { requirement: String }, + Breg { permissions: Vec }, +} + +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaskPermission { + pub collection: String, + pub operations: Vec, +} + +impl TaskGrantBounds { + pub fn check(&self) -> Result<(), TaskGrantError> { + match self { + Self::Evidence { requirement } if bounded(requirement, 512) => Ok(()), + Self::Breg { permissions } if !permissions.is_empty() && permissions.len() <= 64 => { + let mut collections = BTreeSet::new(); + for permission in permissions { + if !bounded(&permission.collection, 512) + || !collections.insert(&permission.collection) + || !unique(&permission.operations, 32) + || permission + .operations + .iter() + .any(|op| !op.bytes().all(|c| c.is_ascii_lowercase() || c == b'_')) + { + return Err(TaskGrantError::Policy); + } + } + Ok(()) + } + _ => Err(TaskGrantError::Policy), + } + } +} + +impl TaskTemplate { + pub fn check(&self, project: &CaseworkProject) -> Result<(), TaskGrantError> { + let source = project + .sources + .iter() + .find(|source| source.id == self.source) + .ok_or(TaskGrantError::Policy)?; + if !crate::valid_directory_identifier(&self.id) + || !bounded(&self.version, 128) + || !bounded(&self.label, 160) + || !unique(&self.eligible_teams, 32) + || self + .eligible_teams + .iter() + .any(|team| !crate::valid_directory_identifier(team)) + || !unique(&self.eligible_profiles, 32) + || self.eligible_profiles.iter().any(|id| { + !project.access_profiles.iter().any(|profile| { + profile.id == *id + && matches!(profile.role, CaseworkRole::Staff | CaseworkRole::Supervisor) + }) + }) + || !unique(&self.item_kinds, 32) + || self.item_kinds.iter().any(|kind| { + !source + .requests + .iter() + .any(|request| request.entity == *kind) + }) + || self.item_states.is_empty() + || self.item_states.len() > 4 + || self.item_states.iter().any(|state| { + !matches!( + state, + OccurrenceState::Claimed + | OccurrenceState::WaitingApplicant + | OccurrenceState::WaitingApplication + ) + }) + || self + .item_states + .iter() + .enumerate() + .any(|(index, state)| self.item_states[..index].contains(state)) + || !bounded(&self.agent.issuer, 512) + || !bounded(&self.agent.subject, 512) + || !bounded(&self.client, 512) + || !bounded(&self.resource, 512) + || !bounded(&self.purpose, 128) + || self.purpose.bytes().any(|byte| { + !(byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'-' | b'_' | b':' | b'.')) + }) + || self.subjects.is_empty() + || self.subjects.len() > 32 + || self.subjects.iter().any(|(claim, field)| { + !crate::valid_directory_identifier(claim) || !bounded(field, 128) + }) + || self.lifetime_seconds == 0 + || self.lifetime_seconds > TASK_GRANT_LIFETIME_SECONDS + { + return Err(TaskGrantError::Policy); + } + self.bounds.check() + } + + pub fn disclosed_subjects( + &self, + disclosed: &BTreeMap, + ) -> Result, TaskGrantError> { + self.subjects + .iter() + .map(|(claim, field)| { + let value = disclosed.get(field).ok_or(TaskGrantError::Subjects)?; + match value { + Value::String(value) if bounded(value, 512) => (), + Value::Bool(_) => (), + Value::Number(number) + if number.as_i64().is_some() || number.as_u64().is_some() => {} + _ => return Err(TaskGrantError::Subjects), + } + Ok((claim.clone(), value.clone())) + }) + .collect() + } +} + +/// Ephemeral exact values from one source read. Never serialized as a work-item view. +pub struct TaskSubjectContext { + pub binding: SourceBinding, + pub values: BTreeMap, +} +impl fmt::Debug for TaskSubjectContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TaskSubjectContext()") + } +} + +/// Proposal identity excludes ordinary source revision churn. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskProposalIdentity { + pub version: String, + pub integrity: Option, + pub generation: String, +} +impl From<&SourceBinding> for TaskProposalIdentity { + fn from(binding: &SourceBinding) -> Self { + Self { + version: binding.version.clone(), + integrity: binding.integrity.clone(), + generation: binding.generation.clone(), + } + } +} + +/// Protected persisted record. No bearer or client private key is retained. +#[derive(Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrant { + pub id: Uuid, + pub item_id: Uuid, + pub template: TaskTemplate, + pub authority: String, + pub source_issuer: String, + pub approver: IssuerPrincipal, + pub approver_profile: String, + pub source_subject: SubjectRef, + pub proposal: TaskProposalIdentity, + pub subjects: BTreeMap, + pub approved_at: u64, + pub expires_at: u64, +} +impl fmt::Debug for TaskGrant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TaskGrant") + .field("expires_at", &self.expires_at) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskApprovalRequest { + pub template_id: String, + pub template_version: String, +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum TaskGrantError { + #[error("the governed task template is invalid")] + Policy, + #[error("the required task subject is not disclosed as a bounded scalar")] + Subjects, +} +fn bounded(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && !value.chars().any(char::is_control) + && !value.contains('*') +} +fn unique(values: &[String], maximum: usize) -> bool { + !values.is_empty() + && values.len() <= maximum + && values.iter().all(|value| bounded(value, 512)) + && values.iter().collect::>().len() == values.len() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn proposal_identity_ignores_only_mutable_source_revision() { + let binding = SourceBinding { + source_revision: "before".into(), + version: "proposal-1".into(), + integrity: Some("digest".into()), + generation: "source-1".into(), + }; + let mut next = binding.clone(); + next.source_revision = "after".into(); + assert!(TaskProposalIdentity::from(&binding) == TaskProposalIdentity::from(&next)); + next.version = "proposal-2".into(); + assert!(TaskProposalIdentity::from(&binding) != TaskProposalIdentity::from(&next)); + } + #[test] + fn exact_bounds_reject_wildcards_duplicate_collections_and_ambiguous_operations() { + for value in [ + serde_json::json!({"type":"breg","permissions":[]}), + serde_json::json!({"type":"evidence","requirement":"*"}), + serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get","get"]}]}), + serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get"]},{"collection":"records","operations":["list"]}]}), + ] { + let bounds: TaskGrantBounds = serde_json::from_value(value).unwrap(); + assert!(bounds.check().is_err()); + } + let bounds: TaskGrantBounds = serde_json::from_value(serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get","create"]}]})).unwrap(); + assert!(bounds.check().is_ok()); + } +} + +/// The exact authorization a human can approve after a current disclosed read. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskTemplatePreview { + pub id: String, + pub version: String, + pub label: String, + pub agent: IssuerPrincipal, + pub client: String, + pub resource: String, + pub purpose: String, + pub bounds: TaskGrantBounds, + pub subjects: BTreeMap, + pub lifetime_seconds: u64, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskTemplatePreviews { + pub item_revision: i64, + pub templates: Vec, +} + +/// Grant metadata. Stored subjects are deliberately absent: preview and approval +/// derive selectors from the caller's current disclosed source representation. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantView { + pub id: Uuid, + pub template_id: String, + pub template_version: String, + pub agent: IssuerPrincipal, + pub client: String, + pub resource: String, + pub purpose: String, + pub bounds: TaskGrantBounds, + pub expires_at: u64, + pub invalidated: bool, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantList { + pub grants: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantRevocation { + pub id: Uuid, + pub invalidated: bool, +} + +/// Short-lived credential response. Deliberately does not implement Debug. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskAssertionResponse { + pub assertion: String, + pub expires_at: u64, + pub grant_expires_at: u64, +} + +/// Fresh status returned only to the service client registered for this resource. +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantStatus { + pub active: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskGrantStatusDetails { + pub grant_id: Uuid, + pub authority: String, + pub source_issuer: String, + pub principal: String, + pub client: String, + pub resource: String, + pub purpose: String, + pub bounds: TaskGrantBounds, + pub subjects: BTreeMap, + pub expires_at: u64, +} diff --git a/crates/registry-casework/Cargo.toml b/crates/registry-casework/Cargo.toml index 96d13728d5..f7a79a7047 100644 --- a/crates/registry-casework/Cargo.toml +++ b/crates/registry-casework/Cargo.toml @@ -23,6 +23,8 @@ workspace = true [dependencies] async-trait.workspace = true axum.workspace = true +base64.workspace = true +registry-platform-crypto.workspace = true chrono = { workspace = true, features = ["serde"] } clap.workspace = true deadpool-postgres.workspace = true @@ -56,6 +58,9 @@ uuid = { workspace = true, features = ["serde"] } zeroize.workspace = true [dev-dependencies] +registry-breg = { workspace = true, features = ["postgres-test"] } +registry-thunderid-tooling.workspace = true +reqwest.workspace = true jsonschema.workspace = true registry-casework-client.workspace = true registry-platform-testing = { workspace = true, features = ["test-utils"] } @@ -102,3 +107,8 @@ required-features = ["postgres-test"] name = "inbox_ordering_postgres" path = "tests/inbox_ordering_postgres.rs" required-features = ["postgres-test"] + +[[test]] +name = "task_grant_invalidation" +path = "tests/task_grant_invalidation.rs" +required-features = ["postgres-test"] diff --git a/crates/registry-casework/migrations/0014_task_grants.sql b/crates/registry-casework/migrations/0014_task_grants.sql new file mode 100644 index 0000000000..666e098092 --- /dev/null +++ b/crates/registry-casework/migrations/0014_task_grants.sql @@ -0,0 +1,105 @@ +-- A version is immutable even after it is retired. Reactivation can select +-- an existing identical version, but cannot replace its policy in place. +CREATE TABLE casework_task_templates ( + template_id text NOT NULL, + template_version text NOT NULL, + document jsonb NOT NULL CHECK (octet_length(document::text) <= 65536), + active boolean NOT NULL DEFAULT false, + PRIMARY KEY(template_id,template_version) +); +CREATE UNIQUE INDEX casework_task_templates_active_idx +ON casework_task_templates(template_id) WHERE active; + +-- Immutable task bounds live with their source item and its erasure lifecycle. +CREATE TABLE casework_task_grants ( + grant_id uuid PRIMARY KEY, + item_id uuid NOT NULL REFERENCES casework_items(item_id) ON DELETE CASCADE, + approver_issuer text NOT NULL, + approver_subject text NOT NULL, + approver_profile text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + record jsonb NOT NULL CHECK (octet_length(record::text) <= 65536), + approved_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL CHECK (expires_at > approved_at AND expires_at <= approved_at + interval '900 seconds'), + invalidated_at timestamptz, + invalidation_reason text CHECK (invalidation_reason IN ('revoked','eligibility','template','source')), + UNIQUE(item_id, approver_issuer, approver_subject, approver_profile, idempotency_key) +); +CREATE INDEX casework_task_grants_active_idx ON casework_task_grants(expires_at,item_id) WHERE invalidated_at IS NULL; +CREATE INDEX casework_task_grants_item_idx ON casework_task_grants(item_id, approved_at, grant_id); + +-- Directory writers replace membership rows inside a transaction. Evaluate +-- eligibility only after they advance the serialized directory revision, so an +-- unchanged replacement does not revoke grants during its temporary delete. +CREATE FUNCTION casework_invalidate_ineligible_tasks(target_item uuid) RETURNS void +LANGUAGE plpgsql AS $$ +DECLARE + lost record; + event uuid; +BEGIN + FOR lost IN + UPDATE casework_task_grants g + SET invalidated_at=now(), invalidation_reason='eligibility' + FROM casework_items i + WHERE i.item_id=g.item_id + AND (target_item IS NULL OR i.item_id=target_item) + AND g.invalidated_at IS NULL + AND g.expires_at>now() + AND ( + i.erased_at IS NOT NULL + OR i.holder_issuer IS DISTINCT FROM g.approver_issuer + OR i.holder_subject IS DISTINCT FROM g.approver_subject + OR NOT (g.record->'template'->'itemStates' ? i.state) + OR i.source_id IS DISTINCT FROM g.record->'template'->>'source' + OR NOT (g.record->'template'->'itemKinds' ? i.subject_kind) + OR i.binding->>'version' IS DISTINCT FROM g.record->'proposal'->>'version' + OR i.binding->>'integrity' IS DISTINCT FROM g.record->'proposal'->>'integrity' + OR i.binding->>'generation' IS DISTINCT FROM g.record->'proposal'->>'generation' + OR NOT EXISTS ( + SELECT 1 FROM casework_memberships m + JOIN casework_queue_service q ON q.team_id=m.team_id + WHERE m.issuer=g.approver_issuer AND m.subject=g.approver_subject + AND m.membership_kind IN ('staff','supervisor') + AND g.record->'template'->'eligibleTeams' ? m.team_id + AND q.queue_id=i.queue_id + ) + ) + RETURNING g.grant_id, i.item_id, i.revision + LOOP + event := gen_random_uuid(); + INSERT INTO casework_history(event_id,item_id,item_revision,kind,occurred_at,profile_id,detail) + VALUES(event,lost.item_id,lost.revision,'task_invalidated',now(),'system:task-grants',jsonb_build_object('grantId',lost.grant_id)); + INSERT INTO casework_audit_outbox(event_id,audit_record) + VALUES(event,jsonb_build_object('event','casework.task_invalidated','eventId',event,'itemId',lost.item_id,'grantId',lost.grant_id,'profileId','system:task-grants')); + END LOOP; +END; +$$; + +CREATE FUNCTION casework_task_directory_changed() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + PERFORM casework_invalidate_ineligible_tasks(NULL); + RETURN NEW; +END; +$$; +CREATE TRIGGER casework_task_directory_changed +AFTER UPDATE OF directory_revision ON casework_meta FOR EACH ROW +EXECUTE FUNCTION casework_task_directory_changed(); + +CREATE FUNCTION casework_task_item_changed() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.erased_at IS NOT NULL THEN + -- Grant records contain the disclosed subject selectors. They belong to + -- the same erasure boundary as the source item, not its tombstone. + DELETE FROM casework_task_grants WHERE item_id=NEW.item_id; + ELSE + PERFORM casework_invalidate_ineligible_tasks(NEW.item_id); + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER casework_task_item_changed +AFTER UPDATE OF holder_issuer,holder_subject,state,queue_id,binding,erased_at +ON casework_items FOR EACH ROW EXECUTE FUNCTION casework_task_item_changed(); diff --git a/crates/registry-casework/src/auth.rs b/crates/registry-casework/src/auth.rs index 58544137c7..25c3253115 100644 --- a/crates/registry-casework/src/auth.rs +++ b/crates/registry-casework/src/auth.rs @@ -39,6 +39,42 @@ impl CaseworkAuthenticator { } } + pub(crate) async fn authenticate_task_client( + &self, + token: &str, + scope: &str, + kind: registry_platform_oidc::ActorKind, + ) -> Result { + validate_compact_access_token(token).map_err(|_| AuthenticationError::Refused)?; + let verified = self + .verifier + .verify(token) + .await + .map_err(|_| AuthenticationError::Refused)?; + if !matches!( + verified.claims.aud.as_ref(), + Some(registry_platform_oidc::Audience::One(_)) + ) && !matches!(verified.claims.aud.as_ref(), Some(registry_platform_oidc::Audience::Many(values)) if values.len() == 1) + || verified.claims.extra.contains_key("act") + || !verified.scopes.iter().any(|value| value == scope) + || verified.matched_client_id().ok().flatten().is_none() + || registry_platform_oidc::actor_kind( + &verified.claims, + ®istry_platform_oidc::ClaimNames::default(), + ) + .ok() + != Some(kind) + || verified + .claims + .extra + .keys() + .any(|key| key.starts_with("registry_grant_")) + { + return Err(AuthenticationError::Refused); + } + Ok(verified) + } + pub async fn authenticate( &self, token: &str, diff --git a/crates/registry-casework/src/config.rs b/crates/registry-casework/src/config.rs index a62afe6bb1..753d8e9cdc 100644 --- a/crates/registry-casework/src/config.rs +++ b/crates/registry-casework/src/config.rs @@ -305,9 +305,23 @@ pub struct RuntimeConfig { pub authentication: AuthenticationConfig, pub audit: AuditConfig, #[serde(default)] + pub task_authority: Option, + #[serde(default)] pub sources: BTreeMap, } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TaskAuthorityConfig { + pub id: String, + pub issuer: String, + pub exchange_audience: String, + pub signing_key_ref: String, + /// Service client IDs mapped to their one protected resource audience. + pub status_clients: BTreeMap, +} + #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -418,6 +432,8 @@ impl std::fmt::Debug for DatabaseConfig { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OidcConfig { + #[serde(default)] + pub allowed_clients: Vec, pub issuer: String, pub audience: String, #[serde(default)] @@ -585,6 +601,32 @@ impl RuntimeConfig { { return Err(RuntimeConfigError::InvalidOidc); } + if let Some(authority) = &self.task_authority { + if authority.id.is_empty() + || authority.id.len() > 128 + || !registry_platform_httputil::valid_resource_uri(&authority.issuer) + || !registry_platform_httputil::valid_resource_uri(&authority.exchange_audience) + || self.authentication.oidc.allowed_clients.is_empty() + || authority.status_clients.len() > 64 + || authority.status_clients.iter().any(|(client, resource)| { + !self.authentication.oidc.allowed_clients.contains(client) + || !registry_platform_httputil::valid_resource_uri(resource) + }) + || project.task_templates.iter().any(|template| { + template.agent.issuer != self.authentication.oidc.issuer + || !self + .authentication + .oidc + .allowed_clients + .contains(&template.client) + || !registry_platform_httputil::valid_resource_uri(&template.resource) + }) + { + return Err(RuntimeConfigError::InvalidOidc); + } + } else if !project.task_templates.is_empty() { + return Err(RuntimeConfigError::InvalidOidc); + } if self.database.runtime_url_ref.is_empty() || self.database.migration_url_ref.is_empty() { return Err(RuntimeConfigError::InvalidDatabaseReference); } @@ -710,7 +752,8 @@ impl RuntimeConfig { vec![Algorithm::RS256, Algorithm::ES256], vec!["at+jwt".to_owned(), "JWT".to_owned()], ) - .with_scope_claim(self.authentication.oidc.scope_claim.clone()); + .with_scope_claim(self.authentication.oidc.scope_claim.clone()) + .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()); Ok((verifier, std::sync::Arc::new(fetcher))) } } diff --git a/crates/registry-casework/src/http.rs b/crates/registry-casework/src/http.rs index 18cd67f9fa..9c3959d98d 100644 --- a/crates/registry-casework/src/http.rs +++ b/crates/registry-casework/src/http.rs @@ -57,6 +57,21 @@ pub fn router(state: HttpState) -> Router { .route("/health", get(health)) .route("/ready", get(ready)) .route("/v1/casework", get(description)) + .route("/.well-known/jwks.json", get(task_jwks)) + .route( + "/v1/work-items/{item_id}/task-templates", + get(preview_task_templates), + ) + .route( + "/v1/work-items/{item_id}/task-grants", + get(list_task_grants).post(approve_task_grant), + ) + .route( + "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke", + post(revoke_task_grant), + ) + .route("/v1/task-grants/{grant_id}/assertion", post(task_assertion)) + .route("/v1/task-grants/{grant_id}/status", get(task_status)) .route("/v1/hosted-items", post(create_hosted_item)) .route("/v1/hosted-items/terminal", get(hosted_terminal_items)) .route("/v1/hosted-items/{item_id}", get(get_hosted_item)) @@ -1133,6 +1148,128 @@ async fn source_event( Ok(StatusCode::ACCEPTED) } +async fn task_jwks(State(state): State) -> Result, HttpError> { + Ok(Json(state.service.task_jwks()?)) +} +async fn preview_task_templates( + State(state): State, + headers: HeaderMap, + Path(item): Path, +) -> Result, HttpError> { + let (actor, token) = authenticate(&state, &headers).await?; + Ok(Json( + state + .service + .preview_tasks(&actor, item, source_profile(&headers)?, token) + .await?, + )) +} +async fn approve_task_grant( + State(state): State, + headers: HeaderMap, + Path(item): Path, + Json(request): Json, +) -> Result, HttpError> { + let (actor, token) = authenticate(&state, &headers).await?; + Ok(Json( + state + .service + .approve_task( + &actor, + item, + if_match(&headers)?, + source_profile(&headers)?, + idempotency_key(&headers)?, + token, + request, + ) + .await?, + )) +} +async fn list_task_grants( + State(state): State, + headers: HeaderMap, + Path(item): Path, +) -> Result, HttpError> { + let (actor, token) = authenticate(&state, &headers).await?; + Ok(Json( + state + .service + .list_tasks(&actor, item, source_profile(&headers)?, token) + .await?, + )) +} +async fn revoke_task_grant( + State(state): State, + headers: HeaderMap, + Path((item, grant)): Path<(Uuid, Uuid)>, + body: Bytes, +) -> Result, HttpError> { + if !body.is_empty() { + return Err(HttpError::Invalid); + } + let (actor, token) = authenticate(&state, &headers).await?; + Ok(Json( + state + .service + .revoke_task(&actor, item, grant, source_profile(&headers)?, token) + .await?, + )) +} +async fn task_client( + state: &HttpState, + headers: &HeaderMap, + scope: &str, + kind: registry_platform_oidc::ActorKind, +) -> Result { + if headers.contains_key(CASEWORK_PROFILE_HEADER) || headers.contains_key(SOURCE_PROFILE_HEADER) + { + return Err(HttpError::AuthenticationRefused); + } + let raw = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or(HttpError::AuthenticationRefused)?; + let token = parse_bearer_token(raw).map_err(|_| HttpError::AuthenticationRefused)?; + state + .authenticator + .authenticate_task_client(token, scope, kind) + .await + .map_err(|_| HttpError::AuthenticationRefused) +} +async fn task_assertion( + State(state): State, + headers: HeaderMap, + Path(grant): Path, + body: Bytes, +) -> Result, HttpError> { + if !body.is_empty() { + return Err(HttpError::Invalid); + } + let client = task_client( + &state, + &headers, + "casework:grants:assert", + registry_platform_oidc::ActorKind::Agent, + ) + .await?; + Ok(Json(state.service.task_assertion(grant, &client).await?)) +} +async fn task_status( + State(state): State, + headers: HeaderMap, + Path(grant): Path, +) -> Result, HttpError> { + let client = task_client( + &state, + &headers, + "casework:grants:status", + registry_platform_oidc::ActorKind::Service, + ) + .await?; + Ok(Json(state.service.task_status(grant, &client).await?)) +} + async fn authenticate<'a>( state: &HttpState, headers: &'a HeaderMap, diff --git a/crates/registry-casework/src/lib.rs b/crates/registry-casework/src/lib.rs index cdf8d214e9..f20447d08f 100644 --- a/crates/registry-casework/src/lib.rs +++ b/crates/registry-casework/src/lib.rs @@ -13,6 +13,7 @@ pub mod schema; mod service; mod source_retention; mod store; +mod task_grants; pub(crate) use clocks::{reconcile_clock_observation, ResolvedClockPolicy}; diff --git a/crates/registry-casework/src/problem.rs b/crates/registry-casework/src/problem.rs index 69f1bd1fb6..4d2408266f 100644 --- a/crates/registry-casework/src/problem.rs +++ b/crates/registry-casework/src/problem.rs @@ -783,6 +783,45 @@ const SOURCE_EVENT: &[ProblemCode] = &[ ProblemCode::RuntimeFailure, ]; +const TASK_AUTHORITY: &[ProblemCode] = &[ + ProblemCode::AuthenticationRefused, + ProblemCode::OperationNotAuthorized, + ProblemCode::ProfileNotAuthorized, + ProblemCode::ProfileNotHuman, + ProblemCode::RequestInvalid, + ProblemCode::PreconditionFailed, + ProblemCode::ServiceUnavailable, + ProblemCode::SourceBadGateway, + ProblemCode::SourceNotFound, + ProblemCode::WorkItemNotVisible, + ProblemCode::WorkItemSourceUnavailable, + ProblemCode::RuntimeFailure, + ProblemCode::WorkItemProposalChanged, + ProblemCode::WorkItemNotOffered, + ProblemCode::WorkItemRecoveryPending, +]; +const TASK_APPROVAL: &[ProblemCode] = &[ + ProblemCode::AuthenticationRefused, + ProblemCode::OperationNotAuthorized, + ProblemCode::ProfileNotAuthorized, + ProblemCode::ProfileNotHuman, + ProblemCode::RequestInvalid, + ProblemCode::RequestUnprocessable, + ProblemCode::RequestUnsupportedMediaType, + ProblemCode::PreconditionFailed, + ProblemCode::PreconditionRequired, + ProblemCode::IdempotencyKeyReused, + ProblemCode::ServiceUnavailable, + ProblemCode::SourceBadGateway, + ProblemCode::SourceNotFound, + ProblemCode::WorkItemNotVisible, + ProblemCode::WorkItemSourceUnavailable, + ProblemCode::RuntimeFailure, + ProblemCode::WorkItemProposalChanged, + ProblemCode::WorkItemNotOffered, + ProblemCode::WorkItemRecoveryPending, +]; + /// Framework problems can occur outside a successful operation dispatch. pub const FRAMEWORK_PROBLEMS: &[ProblemCode] = &[ ProblemCode::RequestBodyTooLarge, @@ -792,6 +831,69 @@ pub const FRAMEWORK_PROBLEMS: &[ProblemCode] = &[ /// Every implemented operation, in path then method order. pub const OPERATION_CONTRACTS: &[OperationContract] = &[ + OperationContract { + method: "GET", + path: "/.well-known/jwks.json", + success_statuses: &[200], + extracts_path: false, + extracts_query: false, + accepts_json: false, + problems: &[ProblemCode::WorkItemNotVisible, ProblemCode::RuntimeFailure], + }, + OperationContract { + method: "GET", + path: "/v1/work-items/{item_id}/task-templates", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: false, + problems: TASK_AUTHORITY, + }, + OperationContract { + method: "GET", + path: "/v1/work-items/{item_id}/task-grants", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: false, + problems: TASK_AUTHORITY, + }, + OperationContract { + method: "POST", + path: "/v1/work-items/{item_id}/task-grants", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: true, + problems: TASK_APPROVAL, + }, + OperationContract { + method: "POST", + path: "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: false, + problems: TASK_AUTHORITY, + }, + OperationContract { + method: "POST", + path: "/v1/task-grants/{grant_id}/assertion", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: false, + problems: TASK_AUTHORITY, + }, + OperationContract { + method: "GET", + path: "/v1/task-grants/{grant_id}/status", + success_statuses: &[200], + extracts_path: true, + extracts_query: false, + accepts_json: false, + problems: TASK_AUTHORITY, + }, OperationContract { method: "POST", path: "/events/sources/{source_id}", diff --git a/crates/registry-casework/src/runtime.rs b/crates/registry-casework/src/runtime.rs index 7b46679ee9..22346d4ef0 100644 --- a/crates/registry-casework/src/runtime.rs +++ b/crates/registry-casework/src/runtime.rs @@ -103,7 +103,13 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> keys, config.authentication.oidc.human_identity.clone(), )); - let service = CaseworkService::new(store.clone(), project.clone(), adapters)?; + let task_authority = config + .task_authority + .as_ref() + .map(|authority| crate::task_grants::TaskAuthority::load(authority, &secrets)) + .transpose()?; + let service = CaseworkService::new(store.clone(), project.clone(), adapters)? + .with_task_authority(task_authority); let audit_secret = resolve_audit_secret(&secrets, &config.audit.hash_key_ref)?; let audit_profile = AuditProfile::production_from_secret_bytes(zeroize::Zeroizing::new( @@ -129,6 +135,11 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> .as_ref(), ); + // A bad signing key or audit configuration must not retire the live + // instance's task templates before this instance can serve requests. + store + .activate_task_templates(&project.task_templates) + .await?; let (worker_stopped, worker_stops) = mpsc::channel(WORKER_STOP_CAPACITY); let mut workers = Vec::new(); let worker_service = service.clone(); @@ -197,6 +208,7 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> store, chain: audit_chain, sink: audit_sink, + identifiers: audit_profile.key_hasher(), }; let audit_health = service.audit_publisher_health(); workers.push(supervise("audit publication", worker_stopped, async move { @@ -334,6 +346,7 @@ struct RuntimeAuditPublisher { store: PostgresStore, chain: Arc, sink: Arc, + identifiers: registry_platform_audit::AuditKeyHasher, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -385,6 +398,7 @@ impl AuditPublicationBackend for RuntimeAuditPublisher { } async fn append(&self, record: Value) -> Result<(), ()> { + let record = published_audit_record(record, &self.identifiers)?; self.chain .append(self.sink.as_ref(), record) .await @@ -432,6 +446,54 @@ async fn publish_audit_pass( Ok(()) } +/// The database outbox is protected accountability data. The external journal +/// carries only event metadata and keyed references, never source selectors, +/// free-text reasons, receipts, or issuer/subject identities. +fn published_audit_record( + record: Value, + identifiers: ®istry_platform_audit::AuditKeyHasher, +) -> Result { + let raw = record.as_object().ok_or(())?; + let mut published = serde_json::Map::new(); + for field in [ + "event", + "eventId", + "profileId", + "itemRevision", + "directoryRevision", + "actorRef", + "accountabilityEventId", + ] { + if let Some(value) = raw.get(field) { + published.insert(field.to_owned(), value.clone()); + } + } + for (field, output) in [ + ("itemId", "itemPseudonym"), + ("grantId", "grantPseudonym"), + ("teamId", "teamPseudonym"), + ("queueId", "queuePseudonym"), + ] { + if let Some(value) = raw.get(field) { + let value = value.as_str().ok_or(())?; + let hash = identifiers + .audit_reference_hash("casework-reference-v1", field, value) + .map_err(|_| ())?; + published.insert(output.to_owned(), Value::String(hash)); + } + } + if let Some(actor) = raw.get("actor").filter(|value| !value.is_null()) { + let issuer = actor.get("issuer").and_then(Value::as_str).ok_or(())?; + let subject = actor.get("subject").and_then(Value::as_str).ok_or(())?; + let canonical = serde_json::to_string(&(issuer, subject)).map_err(|_| ())?; + let hash = identifiers + .audit_reference_hash("casework-principal-v1", "", &canonical) + .map_err(|_| ())?; + published.insert("principalPseudonym".to_owned(), Value::String(hash)); + } + Ok(Value::Object(published)) +} + fn audit_record_with_event_id(event_id: Uuid, mut record: Value) -> Result { let fields = record.as_object_mut().ok_or(())?; let event_id = event_id.to_string(); @@ -478,6 +540,35 @@ mod tests { use crate::service::AuditPublisherHealth; #[cfg(unix)] + #[test] + fn audit_publication_separates_protected_identity_and_source_data() { + let hasher = registry_platform_audit::AuditKeyHasher::unkeyed_dev_only(); + let raw = serde_json::json!({"event":"casework.task_approved", "eventId":"event", "actor":{"issuer":"https://issuer.test","subject":"raw-human"}, "itemId":"raw-item", "grantId":"raw-grant", "detail":{"person_reference":"raw-person"}, "reason":"private reason", "sourceReceipt":{"body":"private body"}, "profileId":"staff"}); + let published = published_audit_record(raw.clone(), &hasher).unwrap(); + let serialized = published.to_string(); + for secret in [ + "raw-human", + "https://issuer.test", + "raw-item", + "raw-grant", + "raw-person", + "private reason", + "private body", + ] { + assert!(!serialized.contains(secret)); + } + assert_eq!(published["eventId"], "event"); + assert_eq!(published["profileId"], "staff"); + assert!(published["principalPseudonym"].as_str().is_some()); + assert_ne!(published["itemPseudonym"], published["grantPseudonym"]); + assert_eq!(published, published_audit_record(raw, &hasher).unwrap()); + assert!(published_audit_record( + serde_json::json!({"actor":{"subject":"missing-issuer"}}), + &hasher + ) + .is_err()); + } + #[test] fn a_refused_audit_secret_names_its_reference_and_the_rule_it_broke() { use std::os::unix::fs::PermissionsExt as _; diff --git a/crates/registry-casework/src/service.rs b/crates/registry-casework/src/service.rs index a9c6f87abd..6d11214f7a 100644 --- a/crates/registry-casework/src/service.rs +++ b/crates/registry-casework/src/service.rs @@ -35,6 +35,7 @@ pub struct CaseworkService { pub(crate) store: PostgresStore, adapters: Arc>>, pub(crate) project: Arc, + pub(crate) task_authority: Option>, audit_publisher_health: AuditPublisherHealth, } @@ -115,6 +116,7 @@ impl CaseworkService { adapters: Arc::new(registered), project: Arc::new(project), audit_publisher_health: AuditPublisherHealth::default(), + task_authority: None, }) } diff --git a/crates/registry-casework/src/store.rs b/crates/registry-casework/src/store.rs index 24e4e42313..abc23adb56 100644 --- a/crates/registry-casework/src/store.rs +++ b/crates/registry-casework/src/store.rs @@ -42,7 +42,7 @@ const SYNC_CLAIM_INDEXES_MIGRATION: &str = include_str!("../migrations/0013_sync_claim_indexes.sql"); /// Every schema version in ledger order. -const MIGRATIONS: [(i64, &str); 13] = [ +const MIGRATIONS: [(i64, &str); 14] = [ (1, MIGRATION), (2, HOSTED_MIGRATION), (3, ASSIGNMENT_MIGRATION), @@ -56,6 +56,7 @@ const MIGRATIONS: [(i64, &str); 13] = [ (11, SOURCE_RECONCILIATION_PROGRESS_MIGRATION), (12, ABSENCE_CURSORS_MIGRATION), (13, SYNC_CLAIM_INDEXES_MIGRATION), + (14, include_str!("../migrations/0014_task_grants.sql")), ]; /// Serializes operator-run migrations on one session lock. A second migrator @@ -3486,6 +3487,9 @@ fn history_kind_name(value: HistoryKind) -> &'static str { HistoryKind::CaseloadMoved => "caseload_moved", HistoryKind::Released => "released", HistoryKind::DraftSaved => "draft_saved", + HistoryKind::TaskApproved => "task_approved", + HistoryKind::TaskRevoked => "task_revoked", + HistoryKind::TaskInvalidated => "task_invalidated", HistoryKind::AttemptReserved => "attempt_reserved", HistoryKind::AttemptUncertain => "attempt_uncertain", HistoryKind::ActionCompleted => "action_completed", @@ -3507,6 +3511,9 @@ fn parse_history(value: &str) -> Result { "caseload_moved" => Ok(HistoryKind::CaseloadMoved), "released" => Ok(HistoryKind::Released), "draft_saved" => Ok(HistoryKind::DraftSaved), + "task_approved" => Ok(HistoryKind::TaskApproved), + "task_revoked" => Ok(HistoryKind::TaskRevoked), + "task_invalidated" => Ok(HistoryKind::TaskInvalidated), "attempt_reserved" => Ok(HistoryKind::AttemptReserved), "attempt_uncertain" => Ok(HistoryKind::AttemptUncertain), "action_completed" => Ok(HistoryKind::ActionCompleted), diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs new file mode 100644 index 0000000000..2bd39b2dfa --- /dev/null +++ b/crates/registry-casework/src/task_grants.rs @@ -0,0 +1,721 @@ +//! Persisted task authority with immutable bounds and live eligibility checks. +use crate::{PostgresStore, StoreError}; +use chrono::{DateTime, Utc}; +use registry_casework_core::{ + ActorContext, CaseworkRole, TaskAssertionResponse, TaskGrant, TaskGrantList, + TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + TaskProposalIdentity, TaskTemplate, TaskTemplatePreview, TaskTemplatePreviews, WorkItem, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio_postgres::Transaction; +use uuid::Uuid; + +pub(crate) struct StoredTaskGrant { + pub grant: TaskGrant, + pub invalidated: bool, +} + +async fn eligible( + transaction: &Transaction<'_>, + actor: &ActorContext, + item: &WorkItem, + template: &TaskTemplate, +) -> Result { + if !matches!(actor.role, CaseworkRole::Staff | CaseworkRole::Supervisor) + || !template.eligible_profiles.contains(&actor.profile_id) + || item.holder.as_ref() != Some(&actor.principal) + || !template.item_states.contains(&item.state) + || item.subject.source_id != template.source + || !template.item_kinds.contains(&item.subject.kind) + { + return Ok(false); + } + let row = transaction.query_one( + "SELECT EXISTS(SELECT 1 FROM casework_memberships m JOIN casework_queue_service q ON q.team_id=m.team_id WHERE m.issuer=$1 AND m.subject=$2 AND m.membership_kind IN ('staff','supervisor') AND m.team_id=ANY($3) AND q.queue_id=$4)", + &[&actor.principal.issuer, &actor.principal.subject, &template.eligible_teams, &item.queue_id], + ).await?; + Ok(row.get(0)) +} + +impl PostgresStore { + /// Activate the configured immutable template versions under the same + /// Directory lock used by approval and live eligibility checks. + pub(crate) async fn activate_task_templates( + &self, + templates: &[TaskTemplate], + ) -> Result<(), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .query_one( + "SELECT directory_revision FROM casework_meta WHERE singleton=true FOR UPDATE", + &[], + ) + .await?; + for template in templates { + let document = serde_json::to_value(template)?; + if serde_json::to_vec(&document)?.len() > 65536 { + return Err(StoreError::Configuration); + } + if let Some(row) = transaction.query_opt("SELECT document FROM casework_task_templates WHERE template_id=$1 AND template_version=$2", &[&template.id,&template.version]).await? { + if row.get::<_,Value>(0) != document { return Err(StoreError::Configuration); } + } else { + transaction.execute("INSERT INTO casework_task_templates(template_id,template_version,document) VALUES($1,$2,$3)", &[&template.id,&template.version,&document]).await?; + } + } + transaction + .execute( + "UPDATE casework_task_templates SET active=false WHERE active", + &[], + ) + .await?; + for template in templates { + transaction.execute("UPDATE casework_task_templates SET active=true WHERE template_id=$1 AND template_version=$2", &[&template.id,&template.version]).await?; + } + let rows = transaction.query("SELECT i.*,g.grant_id FROM casework_task_grants g JOIN casework_items i ON i.item_id=g.item_id WHERE g.invalidated_at IS NULL AND g.expires_at>now() AND NOT EXISTS(SELECT 1 FROM casework_task_templates t WHERE t.active AND t.document=g.record->'template') ORDER BY i.item_id,g.grant_id FOR UPDATE OF i", &[]).await?; + for row in rows { + let item = crate::store::row_to_item(&row)?; + invalidate(&transaction, &item, row.get("grant_id"), "template", None).await?; + } + transaction.commit().await?; + Ok(()) + } + + async fn eligible_task_template( + &self, + actor: &ActorContext, + item_id: Uuid, + template: &TaskTemplate, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .query_one( + "SELECT directory_revision FROM casework_meta WHERE singleton=true FOR SHARE", + &[], + ) + .await?; + let row = transaction + .query_opt( + "SELECT * FROM casework_items WHERE item_id=$1 AND erased_at IS NULL FOR SHARE", + &[&item_id], + ) + .await? + .ok_or(StoreError::NotFound)?; + let item = crate::store::row_to_item(&row)?; + let allowed = template_active(&transaction, template).await? + && eligible(&transaction, actor, &item, template).await?; + transaction.commit().await?; + Ok(allowed) + } + + pub(crate) async fn approve_task_grant( + &self, + actor: &ActorContext, + expected_revision: i64, + key: &str, + grant: TaskGrant, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .query_one( + "SELECT directory_revision FROM casework_meta WHERE singleton=true FOR SHARE", + &[], + ) + .await?; + let row = transaction + .query_opt( + "SELECT * FROM casework_items WHERE item_id=$1 AND erased_at IS NULL FOR UPDATE", + &[&grant.item_id], + ) + .await? + .ok_or(StoreError::NotFound)?; + let item = crate::store::row_to_item(&row)?; + if !template_active(&transaction, &grant.template).await? + || !eligible(&transaction, actor, &item, &grant.template).await? + { + return Err(StoreError::Forbidden); + } + if TaskProposalIdentity::from(&item.binding) != grant.proposal { + return Err(StoreError::Conflict); + } + let request = json!({"item":grant.item_id,"template":grant.template,"proposal":grant.proposal,"subjects":grant.subjects,"authority":grant.authority,"sourceIssuer":grant.source_issuer}); + let hash = Sha256::digest(serde_json::to_vec(&request)?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + if let Some(previous) = transaction.query_opt("SELECT request_hash,record,invalidated_at IS NOT NULL FROM casework_task_grants WHERE item_id=$1 AND approver_issuer=$2 AND approver_subject=$3 AND approver_profile=$4 AND idempotency_key=$5", &[&item.item_id,&actor.principal.issuer,&actor.principal.subject,&actor.profile_id,&key]).await? { + if previous.get::<_,String>(0) != hash { return Err(StoreError::IdempotencyConflict); } + return Ok(StoredTaskGrant { grant: serde_json::from_value(previous.get(1))?, invalidated: previous.get(2) }); + } + if item.revision != expected_revision { + return Err(StoreError::Conflict); + } + let count: i64 = transaction + .query_one( + "SELECT count(*) FROM casework_task_grants WHERE item_id=$1", + &[&item.item_id], + ) + .await? + .get(0); + if count >= 128 { + return Err(StoreError::Invalid); + } + let approved = DateTime::from_timestamp( + i64::try_from(grant.approved_at).map_err(|_| StoreError::Invalid)?, + 0, + ) + .ok_or(StoreError::Invalid)?; + let expires = DateTime::from_timestamp( + i64::try_from(grant.expires_at).map_err(|_| StoreError::Invalid)?, + 0, + ) + .ok_or(StoreError::Invalid)?; + let record = serde_json::to_value(&grant)?; + if serde_json::to_vec(&record)?.len() > 65536 { + return Err(StoreError::Invalid); + } + transaction.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", &[&grant.id,&item.item_id,&actor.principal.issuer,&actor.principal.subject,&actor.profile_id,&key,&hash,&record,&approved,&expires]).await?; + task_event(&transaction, &item, "task_approved", Some(actor), grant.id).await?; + transaction.commit().await?; + Ok(StoredTaskGrant { + grant, + invalidated: false, + }) + } + + pub(crate) async fn task_grant(&self, id: Uuid) -> Result { + let client = self.client().await?; + let row = client.query_opt("SELECT g.record,g.invalidated_at IS NOT NULL FROM casework_task_grants g JOIN casework_items i ON i.item_id=g.item_id WHERE g.grant_id=$1 AND i.erased_at IS NULL", &[&id]).await?.ok_or(StoreError::NotFound)?; + Ok(StoredTaskGrant { + grant: serde_json::from_value(row.get(0))?, + invalidated: row.get(1), + }) + } + + pub(crate) async fn task_grants_for_item( + &self, + item: Uuid, + ) -> Result, StoreError> { + let client = self.client().await?; + client.query("SELECT g.record,g.invalidated_at IS NOT NULL FROM casework_task_grants g JOIN casework_items i ON i.item_id=g.item_id WHERE g.item_id=$1 AND i.erased_at IS NULL ORDER BY g.approved_at,g.grant_id LIMIT 128", &[&item]).await?.into_iter().map(|row| Ok(StoredTaskGrant { grant:serde_json::from_value(row.get(0))?, invalidated:row.get(1) })).collect() + } + + /// The directory snapshot and grant invalidation are serialized with all + /// directory mutations. Once invalidated, later re-eligibility cannot revive it. + pub(crate) async fn check_task_eligibility( + &self, + grant: &TaskGrant, + template: &TaskTemplate, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .query_one( + "SELECT directory_revision FROM casework_meta WHERE singleton=true FOR SHARE", + &[], + ) + .await?; + let Some(row) = transaction + .query_opt( + "SELECT * FROM casework_items WHERE item_id=$1 AND erased_at IS NULL FOR UPDATE", + &[&grant.item_id], + ) + .await? + else { + return Ok(false); + }; + let item = crate::store::row_to_item(&row)?; + let actor = ActorContext { + principal: grant.approver.clone(), + profile_id: grant.approver_profile.clone(), + role: CaseworkRole::Staff, + }; + let valid = template_active(&transaction, template).await? + && eligible(&transaction, &actor, &item, template).await? + && TaskProposalIdentity::from(&item.binding) == grant.proposal; + if !valid { + invalidate(&transaction, &item, grant.id, "eligibility", None).await?; + } + let row = transaction + .query_opt( + "SELECT invalidated_at IS NULL FROM casework_task_grants WHERE grant_id=$1", + &[&grant.id], + ) + .await?; + let active = valid && row.is_some_and(|row| row.get::<_, bool>(0)); + transaction.commit().await?; + Ok(active) + } + + pub(crate) async fn invalidate_task_grant( + &self, + id: Uuid, + reason: &'static str, + actor: Option<&ActorContext>, + ) -> Result<(), StoreError> { + if !matches!(reason, "revoked" | "eligibility" | "template" | "source") { + return Err(StoreError::Invalid); + } + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .query_one( + "SELECT directory_revision FROM casework_meta WHERE singleton=true FOR SHARE", + &[], + ) + .await?; + let row=transaction.query_opt("SELECT i.* FROM casework_items i JOIN casework_task_grants g ON g.item_id=i.item_id WHERE g.grant_id=$1 AND i.erased_at IS NULL FOR UPDATE OF i", &[&id]).await?.ok_or(StoreError::NotFound)?; + let item = crate::store::row_to_item(&row)?; + if let Some(actor) = actor { + let record: Value = transaction + .query_one( + "SELECT record FROM casework_task_grants WHERE grant_id=$1", + &[&id], + ) + .await? + .get(0); + let grant: TaskGrant = serde_json::from_value(record)?; + if !eligible(&transaction, actor, &item, &grant.template).await? { + return Err(StoreError::Forbidden); + } + } + invalidate(&transaction, &item, id, reason, actor).await?; + transaction.commit().await?; + Ok(()) + } +} + +async fn template_active( + transaction: &Transaction<'_>, + template: &TaskTemplate, +) -> Result { + let document = serde_json::to_value(template)?; + Ok(transaction.query_one("SELECT EXISTS(SELECT 1 FROM casework_task_templates WHERE active AND template_id=$1 AND template_version=$2 AND document=$3)", &[&template.id,&template.version,&document]).await?.get(0)) +} + +async fn invalidate( + transaction: &Transaction<'_>, + item: &WorkItem, + id: Uuid, + reason: &str, + actor: Option<&ActorContext>, +) -> Result<(), StoreError> { + if transaction.execute("UPDATE casework_task_grants SET invalidated_at=now(),invalidation_reason=$2 WHERE grant_id=$1 AND invalidated_at IS NULL", &[&id,&reason]).await? == 1 { + task_event(transaction,item,if reason=="revoked" {"task_revoked"} else {"task_invalidated"},actor,id).await?; + } + Ok(()) +} +async fn task_event( + transaction: &Transaction<'_>, + item: &WorkItem, + kind: &str, + actor: Option<&ActorContext>, + grant: Uuid, +) -> Result<(), StoreError> { + let event = Uuid::new_v4(); + let now = Utc::now(); + let issuer = actor.map(|actor| &actor.principal.issuer); + let subject = actor.map(|actor| &actor.principal.subject); + let profile = actor.map_or("system:task-grants", |actor| actor.profile_id.as_str()); + let detail = json!({"grantId":grant}); + transaction.execute("INSERT INTO casework_history(event_id,item_id,item_revision,kind,occurred_at,actor_issuer,actor_subject,profile_id,detail) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)", &[&event,&item.item_id,&item.revision,&kind,&now,&issuer,&subject,&profile,&detail]).await?; + transaction.execute("INSERT INTO casework_audit_outbox(event_id,audit_record) VALUES($1,$2)", &[&event,&json!({"event":format!("casework.{kind}"),"eventId":event,"itemId":item.item_id,"actor":actor.map(|actor|json!({"issuer":actor.principal.issuer,"subject":actor.principal.subject})),"grantId":grant,"profileId":profile})]).await?; + Ok(()) +} + +pub(crate) struct TaskAuthority { + config: crate::TaskAuthorityConfig, + key: registry_platform_crypto::PrivateJwk, +} +impl TaskAuthority { + pub(crate) fn load( + config: &crate::TaskAuthorityConfig, + secrets: ®istry_platform_config::SecretResolver, + ) -> Result { + let secret = secrets + .resolve(&config.signing_key_ref) + .map_err(|_| StoreError::Configuration)?; + let text = + std::str::from_utf8(secret.expose_secret()).map_err(|_| StoreError::Configuration)?; + let key = registry_platform_crypto::PrivateJwk::parse(text) + .map_err(|_| StoreError::Configuration)?; + if !matches!(key.alg.as_deref(), Some("ES256" | "RS256")) + || key.kid.as_deref().is_none_or(str::is_empty) + { + return Err(StoreError::Configuration); + } + Ok(Self { + config: config.clone(), + key, + }) + } + pub(crate) fn jwks(&self) -> Result { + Ok(json!({"keys":[self.key.public()]})) + } + fn assertion(&self, grant: &TaskGrant, now: u64) -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + if now >= grant.expires_at { + return Err(StoreError::Forbidden); + } + let expires = grant.expires_at.min( + now.checked_add(registry_casework_core::TASK_ASSERTION_LIFETIME_SECONDS) + .ok_or(StoreError::Invalid)?, + ); + let payload = json!({"iss":self.config.issuer,"sub":grant.template.agent.subject,"aud":self.config.exchange_audience, + "iat":now,"nbf":now,"exp":expires,"jti":Uuid::new_v4(),"registry_actor_kind":"agent", + "registry_grant_id":grant.id,"registry_grant_authority":grant.authority,"registry_grant_source_issuer":grant.source_issuer, + "registry_grant_client":grant.template.client,"registry_grant_resource":grant.template.resource, + "registry_purpose":grant.template.purpose,"registry_grant_exp":grant.expires_at, + "registry_grant_bounds":grant.template.bounds,"identity":grant.subjects}); + let header = json!({"alg":self.key.alg,"kid":self.key.kid,"typ":"JWT"}); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload)?) + ); + let signature = registry_platform_crypto::sign(input.as_bytes(), &self.key) + .map_err(|_| StoreError::Unavailable)?; + Ok(TaskAssertionResponse { + assertion: format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)), + expires_at: expires, + grant_expires_at: grant.expires_at, + }) + } +} + +impl crate::CaseworkService { + pub(crate) fn with_task_authority(mut self, authority: Option) -> Self { + self.task_authority = authority.map(std::sync::Arc::new); + self + } + pub(crate) fn task_jwks(&self) -> Result { + self.task_authority + .as_ref() + .ok_or(crate::ServiceError::NotFound)? + .jwks() + .map_err(Into::into) + } + pub(crate) async fn preview_tasks( + &self, + actor: &ActorContext, + item_id: Uuid, + source_profile: &str, + token: &str, + ) -> Result { + let (item, _) = self + .caller_item(actor, item_id, source_profile, token) + .await?; + let mut templates = Vec::new(); + if self.task_authority.is_none() { + return Ok(TaskTemplatePreviews { + item_revision: item.revision, + templates, + }); + } + for template in &self.project.task_templates { + if !self + .store + .eligible_task_template(actor, item_id, template) + .await? + { + continue; + } + let fields = template.subjects.values().cloned().collect::>(); + let context = match self + .adapter(&item.subject.source_id)? + .read_task_context( + &item.subject, + &fields, + Some(( + source_profile, + registry_casework_core::EphemeralCredential::new(token), + )), + ) + .await + { + Ok(context) => context, + Err( + registry_casework_core::SourceAdapterError::Denied + | registry_casework_core::SourceAdapterError::Concealed, + ) => continue, + Err(error) => return Err(error.into()), + }; + if context.binding != item.binding { + return Err(StoreError::Conflict.into()); + } + let Ok(subjects) = template.disclosed_subjects(&context.values) else { + continue; + }; + templates.push(TaskTemplatePreview { + id: template.id.clone(), + version: template.version.clone(), + label: template.label.clone(), + agent: template.agent.clone(), + client: template.client.clone(), + resource: template.resource.clone(), + purpose: template.purpose.clone(), + bounds: template.bounds.clone(), + subjects, + lifetime_seconds: template.lifetime_seconds, + }); + } + Ok(TaskTemplatePreviews { + item_revision: item.revision, + templates, + }) + } + + pub(crate) async fn approve_task( + &self, + actor: &ActorContext, + item_id: Uuid, + revision: i64, + source_profile: &str, + key: &str, + token: &str, + request: registry_casework_core::TaskApprovalRequest, + ) -> Result { + let authority = self + .task_authority + .as_ref() + .ok_or(crate::ServiceError::Forbidden)?; + let template = self + .project + .task_templates + .iter() + .find(|template| { + template.id == request.template_id && template.version == request.template_version + }) + .ok_or(crate::ServiceError::Forbidden)?; + let (item, _) = self + .caller_item(actor, item_id, source_profile, token) + .await?; + if item.revision != revision || item.holder.as_ref() != Some(&actor.principal) { + return Err(crate::ServiceError::Forbidden); + } + if !self + .store + .eligible_task_template(actor, item_id, template) + .await? + { + return Err(crate::ServiceError::Forbidden); + } + let fields = template.subjects.values().cloned().collect::>(); + let context = self + .adapter(&item.subject.source_id)? + .read_task_context( + &item.subject, + &fields, + Some(( + source_profile, + registry_casework_core::EphemeralCredential::new(token), + )), + ) + .await?; + if context.binding != item.binding { + return Err(StoreError::Conflict.into()); + } + let subjects = template + .disclosed_subjects(&context.values) + .map_err(|_| crate::ServiceError::Forbidden)?; + let now = now_seconds()?; + let grant = TaskGrant { + id: Uuid::new_v4(), + item_id, + template: template.clone(), + authority: authority.config.id.clone(), + source_issuer: authority.config.issuer.clone(), + approver: actor.principal.clone(), + approver_profile: actor.profile_id.clone(), + source_subject: item.subject.clone(), + proposal: TaskProposalIdentity::from(&context.binding), + subjects, + approved_at: now, + expires_at: now + .checked_add(template.lifetime_seconds) + .ok_or(StoreError::Invalid)?, + }; + let stored = self + .store + .approve_task_grant(actor, revision, key, grant) + .await?; + Ok(grant_view(&stored)) + } + pub(crate) async fn list_tasks( + &self, + actor: &ActorContext, + item: Uuid, + profile: &str, + token: &str, + ) -> Result { + self.caller_item(actor, item, profile, token).await?; + let grants = self.store.task_grants_for_item(item).await?; + Ok(TaskGrantList { + grants: grants.iter().map(grant_view).collect(), + }) + } + pub(crate) async fn revoke_task( + &self, + actor: &ActorContext, + item: Uuid, + id: Uuid, + profile: &str, + token: &str, + ) -> Result { + self.caller_item(actor, item, profile, token).await?; + let stored = self.store.task_grant(id).await?; + if stored.grant.item_id != item { + return Err(crate::ServiceError::NotFound); + } + self.store + .invalidate_task_grant(id, "revoked", Some(actor)) + .await?; + Ok(TaskGrantRevocation { + id, + invalidated: true, + }) + } + async fn active_task(&self, id: Uuid) -> Result { + let stored = self.store.task_grant(id).await?; + if stored.invalidated || now_seconds()? >= stored.grant.expires_at { + return Err(crate::ServiceError::Forbidden); + } + let grant = &stored.grant; + let Some(template) = self + .project + .task_templates + .iter() + .find(|template| **template == grant.template) + else { + self.store + .invalidate_task_grant(id, "template", None) + .await?; + return Err(crate::ServiceError::Forbidden); + }; + if !self.store.check_task_eligibility(grant, template).await? { + return Err(crate::ServiceError::Forbidden); + } + let fields = template.subjects.values().cloned().collect::>(); + let context = self + .adapter(&grant.source_subject.source_id)? + .read_task_context(&grant.source_subject, &fields, None) + .await; + let valid = match context { + Ok(context) => { + TaskProposalIdentity::from(&context.binding) == grant.proposal + && template + .disclosed_subjects(&context.values) + .is_ok_and(|subjects| subjects == grant.subjects) + } + Err( + registry_casework_core::SourceAdapterError::Concealed + | registry_casework_core::SourceAdapterError::Denied + | registry_casework_core::SourceAdapterError::BindingMoved + | registry_casework_core::SourceAdapterError::DefinitiveRefusal, + ) => false, + Err(error) => return Err(error.into()), + }; + if !valid { + self.store.invalidate_task_grant(id, "source", None).await?; + return Err(crate::ServiceError::Forbidden); + } + // Recheck after source I/O so revocation during the read cannot release an assertion. + if !self.store.check_task_eligibility(grant, template).await? + || now_seconds()? >= grant.expires_at + { + return Err(crate::ServiceError::Forbidden); + } + Ok(stored) + } + pub(crate) async fn task_assertion( + &self, + id: Uuid, + client: ®istry_platform_oidc::VerifiedToken, + ) -> Result { + let stored = self.store.task_grant(id).await?; + if client.matched_client_id().ok().flatten() != Some(stored.grant.template.client.as_str()) + || client.claims.sub.as_deref() != Some(stored.grant.template.agent.subject.as_str()) + || client.claims.iss.as_deref() != Some(stored.grant.template.agent.issuer.as_str()) + { + return Err(crate::ServiceError::NotFound); + } + let stored = self.active_task(id).await?; + self.task_authority + .as_ref() + .ok_or(crate::ServiceError::Forbidden)? + .assertion(&stored.grant, now_seconds()?) + .map_err(Into::into) + } + pub(crate) async fn task_status( + &self, + id: Uuid, + client: ®istry_platform_oidc::VerifiedToken, + ) -> Result { + let authority = self + .task_authority + .as_ref() + .ok_or(crate::ServiceError::Forbidden)?; + let client = client + .matched_client_id() + .ok() + .flatten() + .ok_or(crate::ServiceError::Forbidden)?; + let resource = authority + .config + .status_clients + .get(client) + .ok_or(crate::ServiceError::Forbidden)?; + let stored = self.store.task_grant(id).await?; + if stored.grant.template.resource != *resource { + return Err(crate::ServiceError::NotFound); + } + match self.active_task(id).await { + Ok(stored) => Ok(TaskGrantStatus { + active: true, + grant: Some(TaskGrantStatusDetails { + grant_id: stored.grant.id, + authority: stored.grant.authority, + source_issuer: stored.grant.source_issuer, + principal: stored.grant.template.agent.subject, + client: stored.grant.template.client, + resource: stored.grant.template.resource, + purpose: stored.grant.template.purpose, + bounds: stored.grant.template.bounds, + subjects: stored.grant.subjects, + expires_at: stored.grant.expires_at, + }), + }), + Err(crate::ServiceError::Forbidden) => Ok(TaskGrantStatus { + active: false, + grant: None, + }), + Err(error) => Err(error), + } + } +} +fn now_seconds() -> Result { + u64::try_from(Utc::now().timestamp()).map_err(|_| StoreError::Invalid) +} +fn grant_view(stored: &StoredTaskGrant) -> TaskGrantView { + TaskGrantView { + id: stored.grant.id, + template_id: stored.grant.template.id.clone(), + template_version: stored.grant.template.version.clone(), + agent: stored.grant.template.agent.clone(), + client: stored.grant.template.client.clone(), + resource: stored.grant.template.resource.clone(), + purpose: stored.grant.template.purpose.clone(), + bounds: stored.grant.template.bounds.clone(), + expires_at: stored.grant.expires_at, + invalidated: stored.invalidated, + } +} + +#[cfg(all(test, feature = "postgres-test"))] +mod tests; + +#[cfg(all(test, feature = "postgres-test"))] +mod http_tests; diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs new file mode 100644 index 0000000000..31eded522c --- /dev/null +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -0,0 +1,570 @@ +use super::*; +use async_trait::async_trait; +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + Router, +}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use registry_casework_core::*; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tower::ServiceExt; + +const ISSUER: &str = "https://task-token.test"; +const SECRET: &[u8] = b"01234567890123456789012345678901"; + +fn binding() -> SourceBinding { + SourceBinding { + source_revision: "1".into(), + version: "proposal-1".into(), + integrity: None, + generation: "generation-1".into(), + } +} +struct Source { + mode: Arc, +} +#[async_trait] +impl SourceAdapter for Source { + fn source_id(&self) -> &str { + "source" + } + fn binding_generation(&self) -> &str { + "generation-1" + } + async fn verify_transition( + &self, + _: EventRequest, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn read_authoritative( + &self, + _: &SubjectRef, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn discover_active( + &self, + _: Option<&DiscoveryCursor>, + _: usize, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn read_for_caller( + &self, + subject: &SubjectRef, + _: &str, + _: EphemeralCredential<'_>, + ) -> Result { + Ok(CallerSubjectView { + display_reference: None, + subject: subject.clone(), + binding: binding(), + disclosed: Default::default(), + permitted_operations: Vec::new(), + }) + } + async fn read_task_context( + &self, + _: &SubjectRef, + fields: &[String], + caller: Option<(&str, EphemeralCredential<'_>)>, + ) -> Result { + assert_eq!(fields, &["person-reference"]); + match self.mode.load(Ordering::SeqCst) { + 1 => return Err(SourceAdapterError::Unavailable), + 3 if caller.is_some() => return Err(SourceAdapterError::Denied), + 4 if caller.is_none() => { + tokio::time::sleep(std::time::Duration::from_millis(1250)).await + } + _ => (), + } + let person = if self.mode.load(Ordering::SeqCst) == 2 { + "different-person" + } else { + "synthetic-person" + }; + Ok(TaskSubjectContext { + binding: binding(), + values: std::collections::BTreeMap::from([("person-reference".into(), json!(person))]), + }) + } + async fn prepare_action( + &self, + _: PrepareActionRequest<'_>, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn execute_prepared( + &self, + _: ExecutePreparedRequest<'_>, + ) -> Result { + Err(SourceAdapterError::Invalid) + } +} + +fn token(subject: &str, client: &str, kind: &str, scope: &str) -> String { + token_claims(json!({"sub":subject,"azp":client,"registry_actor_kind":kind,"scope":scope})) +} +fn token_claims(mut claims: Value) -> String { + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.insert("iss".into(), json!(ISSUER)); + object.entry("aud").or_insert(json!("urn:casework:test")); + object.insert("iat".into(), json!(now)); + object.insert("exp".into(), json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".into()); + header.typ = Some("at+jwt".into()); + encode(&header, &claims, &EncodingKey::from_secret(SECRET)).unwrap() +} +struct Fixture { + app: Router, + mode: Arc, + item: Uuid, + admin: tokio_postgres::Client, + schema: String, + store: PostgresStore, +} +async fn fixture(lifetime: u64) -> Fixture { + let base = std::env::var("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL") + .expect("disposable database is required"); + let schema = format!("task_http_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .unwrap(); + let separator = if base.contains('?') { '&' } else { '?' }; + let url = format!("{base}{separator}options=-csearch_path%3D{schema}"); + let name = format!("CASEWORK_HTTP_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&name, &url); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp").unwrap(); + let config = crate::DatabaseConfig { + runtime_url_ref: format!("secret:env/{name}"), + migration_url_ref: format!("secret:env/{name}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + let store = PostgresStore::connect_migration(&config, &secrets).unwrap(); + store.migrate().await.unwrap(); + std::env::remove_var(name); + let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); + store + .activate_task_templates(&project.task_templates) + .await + .unwrap(); + let db = store.client().await.unwrap(); + db.execute( + "INSERT INTO casework_teams(team_id,revision) VALUES('team',1)", + &[], + ) + .await + .unwrap(); + db.execute("INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,'human','staff')",&[&ISSUER]).await.unwrap(); + db.execute( + "INSERT INTO casework_queue_service(queue_id,team_id,revision) VALUES('review','team',1)", + &[], + ) + .await + .unwrap(); + let item = Uuid::new_v4(); + db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,holder_issuer,holder_subject,revision,first_observed_at,updated_at) VALUES($1,'source','request','request-1','review','review-1',$2,'claimed','review',$3,'human',1,now(),now())",&[&item,&serde_json::to_value(binding()).unwrap(),&ISSUER]).await.unwrap(); + let mode = Arc::new(AtomicUsize::new(0)); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + let authority = TaskAuthority { + config: crate::TaskAuthorityConfig { + id: "casework-tasks".into(), + issuer: "https://task-authority.test".into(), + exchange_audience: "https://issuer.test/token".into(), + signing_key_ref: "secret:env/TEST_ONLY".into(), + status_clients: std::collections::BTreeMap::from([ + ("breg-status".into(), "urn:breg:test".into()), + ("other-resource".into(), "urn:other:test".into()), + ]), + }, + key, + }; + let service = crate::CaseworkService::new( + store.clone(), + project.clone(), + [Arc::new(Source { mode: mode.clone() }) as Arc], + ) + .unwrap() + .with_task_authority(Some(authority)); + let verifier = TokenVerifierConfig::access_token_profile( + ISSUER, + vec!["urn:casework:test".into()], + vec![Algorithm::HS256], + vec!["at+jwt".into()], + ) + .with_scope_claim("scope") + .with_allowed_clients( + [ + "human-client", + "agent-client", + "other-agent", + "breg-status", + "other-resource", + ] + .into_iter() + .map(str::to_owned) + .collect(), + ); + let jwks=serde_json::from_value(json!({"keys":[{"kty":"oct","kid":"test","alg":"HS256","use":"sig","k":"MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE"}]})).unwrap(); + let authenticator = crate::CaseworkAuthenticator::new( + &project, + verifier, + Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())), + crate::HumanIdentityConfig::default(), + ); + let app = crate::router(crate::HttpState { + service, + authenticator: Arc::new(authenticator), + project: Arc::new(project), + }); + Fixture { + app, + mode, + item, + admin, + schema, + store, + } +} +async fn request( + f: &Fixture, + method: &str, + path: &str, + token: &str, + human: bool, + body: Option, + key: Option<&str>, +) -> (StatusCode, Value) { + let mut req = Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")); + if human { + req = req + .header(CASEWORK_PROFILE_HEADER, "staff") + .header(SOURCE_PROFILE_HEADER, "source-reader"); + } + if let Some(key) = key { + req = req + .header(IF_MATCH_HEADER, "\"1\"") + .header(IDEMPOTENCY_KEY_HEADER, key); + } + let body = if let Some(body) = body { + req = req.header("content-type", "application/json"); + Body::from(body.to_string()) + } else { + Body::empty() + }; + let response = f + .app + .clone() + .oneshot(req.body(body).unwrap()) + .await + .unwrap(); + let status = response.status(); + assert!(response + .headers() + .get("cache-control") + .is_some_and(|value| value.to_str().unwrap().contains("no-store"))); + let bytes = to_bytes(response.into_body(), 65536).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +#[tokio::test] +async fn task_http_approval_assertion_status_and_revocation_enforce_current_authority() { + let f = fixture(900).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let resource = token( + "resource", + "breg-status", + "service", + "casework:grants:status", + ); + let base = format!("/v1/work-items/{}/task-grants", f.item); + let preview = format!("/v1/work-items/{}/task-templates", f.item); + let (status, body) = request(&f, "GET", &preview, &human, true, None, None).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["templates"][0]["subjects"]["person_reference"], + "synthetic-person" + ); + let approval = json!({"templateId":"summary","templateVersion":"1"}); + let (status, body) = request( + &f, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"summary","templateVersion":"1","resource":"urn:attacker"})), + Some("extra-field"), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + f.mode.store(3, Ordering::SeqCst); + let (status, body) = request( + &f, + "POST", + &base, + &human, + true, + Some(approval.clone()), + Some("approve"), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + f.mode.store(0, Ordering::SeqCst); + let (status, approved) = request( + &f, + "POST", + &base, + &human, + true, + Some(approval.clone()), + Some("approve"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{approved}"); + assert!(approved.get("subjects").is_none()); + let id = approved["id"].as_str().unwrap(); + let assertion_path = format!("/v1/task-grants/{id}/assertion"); + let status_path = format!("/v1/task-grants/{id}/status"); + let (_, retried) = request( + &f, + "POST", + &base, + &human, + true, + Some(approval), + Some("approve"), + ) + .await; + assert_eq!( + retried, approved, + "idempotent approval preserves identifier and deadline" + ); + let wrong = token("agent", "other-agent", "agent", "casework:grants:assert"); + assert_eq!( + request(&f, "POST", &assertion_path, &wrong, false, None, None) + .await + .0, + StatusCode::NOT_FOUND + ); + assert_eq!( + request(&f, "POST", &assertion_path, &agent, true, None, None) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let multi = token_claims( + json!({"sub":"agent","azp":"agent-client","registry_actor_kind":"agent","scope":"casework:grants:assert","aud":["urn:casework:test","urn:breg:test"]}), + ); + assert_eq!( + request(&f, "POST", &assertion_path, &multi, false, None, None) + .await + .0, + StatusCode::UNAUTHORIZED + ); + let (status, assertion) = request(&f, "POST", &assertion_path, &agent, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{assertion}"); + let raw = assertion["assertion"].as_str().unwrap(); + use base64::Engine; + let (_, jwks) = request( + &f, + "GET", + "/.well-known/jwks.json", + &human, + false, + None, + None, + ) + .await; + let keys = JwksFetcher::new_static( + serde_json::from_value(jwks).unwrap(), + JwksFetcherConfig::defaults(), + ); + let verifier = registry_platform_oidc::TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://task-authority.test", + vec!["https://issuer.test/token".into()], + vec![Algorithm::RS256], + vec!["JWT".into()], + ), + Arc::new(keys), + ); + verifier + .verify(raw) + .await + .expect("the returned assertion verifies against the served public keys"); + let payload: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(raw.split('.').nth(1).unwrap()) + .unwrap(), + ) + .unwrap(); + assert_eq!(payload["registry_grant_exp"], approved["expiresAt"]); + assert!(payload["exp"].as_u64().unwrap() - payload["iat"].as_u64().unwrap() <= 60); + assert_eq!(payload["identity"]["person_reference"], "synthetic-person"); + assert_eq!(payload["sub"], "agent"); + let (status, active) = request(&f, "GET", &status_path, &resource, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{active}"); + assert_eq!(active["active"], true); + assert_eq!(active["grant"]["grantId"], id); + let other = token( + "resource", + "other-resource", + "service", + "casework:grants:status", + ); + assert_eq!( + request(&f, "GET", &status_path, &other, false, None, None) + .await + .0, + StatusCode::NOT_FOUND + ); + f.mode.store(1, Ordering::SeqCst); + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::SERVICE_UNAVAILABLE + ); + f.mode.store(0, Ordering::SeqCst); + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::OK, + "temporary unavailability must not revoke" + ); + f.mode.store(2, Ordering::SeqCst); + assert_eq!( + request(&f, "GET", &status_path, &resource, false, None, None) + .await + .1["active"], + false + ); + f.mode.store(0, Ordering::SeqCst); + assert_eq!( + request(&f, "GET", &status_path, &resource, false, None, None) + .await + .1["active"], + false, + "source selector regain must not revive grant" + ); + let (status, second) = request( + &f, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"summary","templateVersion":"1"})), + Some("new-approval"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{second}"); + assert_ne!(second["id"], approved["id"]); + let second_id = second["id"].as_str().unwrap(); + let revoke = format!("{base}/{second_id}/revoke"); + assert_eq!( + request(&f, "POST", &revoke, &human, true, None, None) + .await + .0, + StatusCode::OK + ); + let second_status = format!("/v1/task-grants/{second_id}/status"); + assert_eq!( + request(&f, "GET", &second_status, &resource, false, None, None) + .await + .1["active"], + false + ); + for extra in [ + json!({"act":{"sub":"human"}}), + json!({"registry_grant_id":"forged"}), + ] { + let mut claims = json!({"sub":"agent","azp":"agent-client","registry_actor_kind":"agent","scope":"casework:grants:assert"}); + claims + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + assert_eq!( + request( + &f, + "POST", + &assertion_path, + &token_claims(claims), + false, + None, + None + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + } + let stored = f + .store + .task_grant(Uuid::parse_str(id).unwrap()) + .await + .unwrap(); + assert!(stored.invalidated); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + +#[tokio::test] +async fn status_cannot_outlive_grant_deadline_during_source_read() { + let f = fixture(1).await; + let human = token("human", "human-client", "human", "casework:staff"); + let resource = token( + "resource", + "breg-status", + "service", + "casework:grants:status", + ); + let path = format!("/v1/work-items/{}/task-grants", f.item); + let (status, grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(json!({"templateId":"summary","templateVersion":"1"})), + Some("short-grant"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + f.mode.store(4, Ordering::SeqCst); + let path = format!("/v1/task-grants/{}/status", grant["id"].as_str().unwrap()); + let (status, result) = request(&f, "GET", &path, &resource, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["active"], false); + assert!(result.get("grant").is_none()); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs new file mode 100644 index 0000000000..07d525b0ed --- /dev/null +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -0,0 +1,109 @@ +//! Actual Casework approval -> native RFC 8693 exchange -> BREG PostgreSQL mutation. +//! Credentials and protected response bodies stay in memory and never enter logs or argv. +use super::*; +use async_trait::async_trait; +use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}, Router}; +use registry_casework_core::*; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; +use registry_thunderid_tooling::{description::*, local, render, container::Session}; +use std::{collections::BTreeMap, sync::{Arc, atomic::{AtomicUsize,Ordering}}, time::Duration, path::Path, os::unix::fs::PermissionsExt}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD,Engine}; +#[path = "native_resource.rs"] +mod resource; +const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; +const BREG_RESOURCE: &str = "urn:breg:task-test"; +const AUTHORITY: &str = "https://casework.example"; +const IMAGE: &str = "sha256:9f16ec5995a5d23220055fba152f7d8d32fdde5083f87866fbe345b53810ce79"; +fn binding() -> SourceBinding { + SourceBinding { + source_revision: "1".into(), + version: "proposal-1".into(), + integrity: None, + generation: "generation-1".into(), + } +} +struct Source { + mode: Arc, +} +#[async_trait] +impl SourceAdapter for Source { + fn source_id(&self) -> &str { + "source" + } + fn binding_generation(&self) -> &str { + "generation-1" + } + async fn verify_transition( + &self, + _: EventRequest, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn read_authoritative( + &self, + _: &SubjectRef, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn discover_active( + &self, + _: Option<&DiscoveryCursor>, + _: usize, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn read_for_caller( + &self, + subject: &SubjectRef, + _: &str, + _: EphemeralCredential<'_>, + ) -> Result { + Ok(CallerSubjectView { + display_reference: None, + subject: subject.clone(), + binding: binding(), + disclosed: Default::default(), + permitted_operations: Vec::new(), + }) + } + async fn read_task_context( + &self, + _: &SubjectRef, + fields: &[String], + caller: Option<(&str, EphemeralCredential<'_>)>, + ) -> Result { + assert_eq!(fields, &["tenant"]); + match self.mode.load(Ordering::SeqCst) { + 1 => return Err(SourceAdapterError::Unavailable), + 3 if caller.is_some() => return Err(SourceAdapterError::Denied), + 4 if caller.is_none() => { + tokio::time::sleep(std::time::Duration::from_millis(1250)).await + } + _ => (), + } + let person = if self.mode.load(Ordering::SeqCst) == 2 { + "different-person" + } else { + "tenant-a" + }; + Ok(TaskSubjectContext { + binding: binding(), + values: std::collections::BTreeMap::from([("tenant".into(), json!(person))]), + }) + } + async fn prepare_action( + &self, + _: PrepareActionRequest<'_>, + ) -> Result { + Err(SourceAdapterError::Invalid) + } + async fn execute_prepared( + &self, + _: ExecutePreparedRequest<'_>, + ) -> Result { + Err(SourceAdapterError::Invalid) + } +} + diff --git a/crates/registry-casework/src/task_grants/native_resource.rs b/crates/registry-casework/src/task_grants/native_resource.rs new file mode 100644 index 0000000000..6bb13029ef --- /dev/null +++ b/crates/registry-casework/src/task_grants/native_resource.rs @@ -0,0 +1,390 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Synthetic governed-request fixture for the complete native task journey. +#![allow(dead_code, unused_imports)] +#[path = "../../../registry-breg/tests/support/postgres_harness.rs"] +mod postgres_harness; +use axum::{ + body::{to_bytes, Body}, + http::{Method, Request, StatusCode}, + Router, +}; +pub(super) use postgres_harness::TestDatabase; +use registry_breg::api::{ + authenticated_router, HttpService, ReadRuntimeIdentity, ReadinessProbe, ServiceFuture, +}; +use registry_breg::auth::{AuthorityClaimConfig, RegistryAuthenticator}; +use registry_breg::cursor::CursorCodec; +use registry_breg::postgres::{ + initialize_compiled_registry_state_for_test, install_compiled_schema, ExpectedRegistryIdentity, + PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, + RegistryStateTestIdentity, +}; +use registry_breg::task_grant::{TaskGrantBinding, TaskGrantError, TaskGrantStatusChecker}; +use registry_breg::{compile_project, parse_project_json, CompileProfile, CompiledRegistry}; +use registry_platform_audit::AuditProfile; +use registry_platform_httputil::FetchUrlPolicy; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; +use tower::ServiceExt; +use uuid::Uuid; +use zeroize::Zeroizing; +const PACKAGE: &str = "task-authority-http"; +const AUDIENCE: &str = "urn:breg:task-test"; +const REVISION: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SOURCE: &str = "https://casework.example"; +pub(super) const PROJECT: &str = r#"{ + "apiVersion":"registry.registrystack.org/v1alpha1", + "kind":"RegistryProject", + "registry":{"id":"task-authority-http","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://authoring.example.test"}, + "entities":[ + { + "id":"asset-site", + "primaryDataset":"test-dataset", + "route":"sites", + "mutationMode":"create_only", + "classification":"internal", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"name","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"} + ] + }, + { + "id":"asset-placement", + "primaryDataset":"test-dataset", + "route":"placements", + "mutationMode":"mutable", + "classification":"internal", + "changeControl":{"requiredFor":["patch"]}, + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"site","type":"reference","target":"asset-site","required":true,"classification":"internal"} + ] + }, + { + "id":"correction-request", + "primaryDataset":"test-dataset", + "route":"correction-requests", + "mutationMode":"mutable", + "classification":"internal", + "fields":[ + {"id":"tenant","type":"string","minLength":1,"maxLength":64,"required":true,"classification":"internal"}, + {"id":"placement","type":"reference","target":"asset-placement","required":true,"classification":"internal"}, + {"id":"proposed-site","type":"reference","target":"asset-site","required":true,"classification":"internal"}, + {"id":"reason","type":"text","maxLength":1000,"required":true,"classification":"internal"} + ], + "changeRequest":{ + "effects":[{"target":{"fromField":"placement"},"operation":"patch","set":{"site":{"fromField":"proposed-site"}}}], + "review":{"stages":[{"id":"review","approvals":1,"excludeSubmitter":true}]} + } + } + ], + "accessProfiles":[ + { + "id":"steward", + "default":true, + "principalClaim":"sub", + "permissions":[ + { + "entity":"asset-site", + "operations":["create","get","list"], + "readableFields":["tenant","name"], + "writableFields":["tenant","name"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] + }, + { + "entity":"asset-placement", + "operations":["create","get","list","revisions"], + "revisionAccess":true, + "readableFields":["tenant","site"], + "writableFields":["tenant","site"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "requestPresence":[{"requestType":"correction-request","rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}] + } + ] + }, + { + "id":"submitter", + "default":true, + "principalClaim":"sub", + "permissions":[ + { + "entity":"correction-request", + "operations":["create","get","list","revisions","patch","submit_request","revise_request","cancel_request"], + "revisionAccess":true, + "readableFields":["tenant","placement","proposed-site","reason"], + "writableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}] + } + ], + "actorKind":"agent", + "requesterClients":["task-agent"], + "requiredPurposes":["review"], + "taskGrant":{"authority":"casework","sourceIssuer":"https://casework.example"} + }, + { + "id":"reviewer", + "principalClaim":"sub", + "requiredPurposes":["review"], + "permissions":[ + { + "entity":"correction-request", + "operations":["get","list","approve_request","reject_request","request_revision"], + "readableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "reviewStages":[{"stage":"review","targets":[{"entity":"asset-placement","readableFields":["site"],"rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}]}] + } + ] + }, + { + "id":"applier", + "principalClaim":"sub", + "requiredPurposes":["apply"], + "permissions":[ + { + "entity":"correction-request", + "operations":["get","apply_request"], + "readableFields":["tenant","placement","proposed-site","reason"], + "rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}], + "applyTargets":[{"entity":"asset-placement","rowBoundaries":[{"field":"tenant","claim":"tenant_claim","operator":"equals"}]}] + } + ] + } + ] +}"#; + +struct Ready; +impl ReadinessProbe for Ready { + fn is_ready(&self) -> ServiceFuture<'_, bool> { + Box::pin(async { true }) + } +} +pub(super) async fn install(db: &TestDatabase, registry: &CompiledRegistry) -> ExpectedRegistryIdentity { + let (migration, task) = db.connect_migration().await; + install_compiled_schema(&migration, registry, &db.runtime_role) + .await + .unwrap(); + let identity = initialize_compiled_registry_state_for_test( + &migration, + &db.runtime_role, + registry, + RegistryStateTestIdentity { + package_id: PACKAGE, + environment: "local", + instance_id: "task-instance", + database_id: "task-database", + package_revision: REVISION, + package_sequence: 1, + }, + ) + .await + .unwrap(); + drop(migration); + task.abort(); + identity +} +pub(super) fn app( + db: &TestDatabase, + registry: Arc, + identity: ExpectedRegistryIdentity, + issuer: &str, + jwks: serde_json::Value, + status: Arc, +) -> Router { + let pool = db.runtime_config.build_pool().unwrap(); + let lock = RegistryLockKey::derive(PACKAGE).unwrap(); + let audit = AuditProfile::production_from_secret_bytes(vec![0x9a; 32].into()).unwrap(); + let cursors = Arc::new( + CursorCodec::new(Zeroizing::new(vec![0x49; 32]), Duration::from_secs(300)).unwrap(), + ); + let reads = Arc::new(PostgresRecordReadService::new( + pool.clone(), + registry.clone(), + identity.clone(), + lock, + Duration::from_secs(2), + audit.clone(), + cursors.clone(), + )); + let writes = Arc::new( + PostgresRecordMutationService::new( + pool, + registry.clone(), + identity.clone(), + lock, + Duration::from_secs(2), + audit, + ) + .with_task_status(status), + ); + let keys = Arc::new(JwksFetcher::new_static(serde_json::from_value(jwks).unwrap(), JwksFetcherConfig::defaults())); + let verifier = registry_platform_oidc::TokenVerifierConfig::access_token_profile(issuer, vec![AUDIENCE.into()], vec![jsonwebtoken::Algorithm::RS256], vec!["JWT".into(), "at+jwt".into()]).with_scope_claim("scope").with_allowed_clients(vec!["task-agent".into(),"seed-client".into()]); + let auth = RegistryAuthenticator::new( + ®istry, + verifier, + keys, + AuthorityClaimConfig::new("sub", Some("registry_purpose".into())), + ) + .unwrap(); + authenticated_router( + Arc::new( + HttpService::new( + registry, + ReadRuntimeIdentity { + package_revision: identity.package_revision, + schema_fingerprint: identity.schema_fingerprint, + }, + reads, + Arc::new(Ready), + cursors, + ) + .with_postgres_mutations(writes), + ), + Arc::new(auth), + ) +} +pub(super) struct Response { + pub(super) status: StatusCode, + pub(super) body: Value, + pub(super) etag: String, +} +pub(super) async fn send( + app: &Router, + method: Method, + uri: &str, + token: &str, + key: Option<&str>, + etag: Option<&str>, + body: Value, +) -> Response { + let mut req = Request::builder() + .method(method.clone()) + .uri(uri) + .header("authorization", format!("Bearer {token}")); + if let Some(key) = key { + req = req.header("idempotency-key", key); + } + if let Some(etag) = etag { + req = req.header("if-match", etag); + } + if method == Method::PATCH { + req = req.header("content-type", "application/json-patch+json"); + } else { + req = req.header("content-type", "application/json"); + } + let raw = if method == Method::GET { + Vec::new() + } else { + serde_json::to_vec(&body).unwrap() + }; + let response = app + .clone() + .oneshot(req.body(Body::from(raw)).unwrap()) + .await + .unwrap(); + let status = response.status(); + let etag = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let body = serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + Response { status, etag, body } +} +pub(super) async fn create(app: &Router, route: &str, token: &str, key: &str, data: Value) -> Response { + let r = send( + app, + Method::POST, + route, + token, + Some(key), + None, + json!({"data":data}), + ) + .await; + assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); + r +} +pub(super) fn id(response: &Response) -> String { + response.body["data"]["recordIdentifier"] + .as_str() + .unwrap() + .into() +} +pub(super) async fn get(app: &Router, id: &str, profile: &str, token: &str) -> Response { + let r = send( + app, + Method::GET, + &format!("/v1/records/correction-requests/{id}?accessProfile={profile}"), + token, + None, + None, + Value::Null, + ) + .await; + assert_eq!(r.status, StatusCode::OK, "{}", r.body); + r +} +fn action(r: &Response, operation: &str) -> Value { + r.body["data"]["request"]["actions"] + .as_array() + .unwrap() + .iter() + .find(|a| a["operation"] == operation) + .unwrap() + .clone() +} +async fn perform(app: &Router, action: &Value, token: &str, key: &str) -> Response { + let mut body = if action.get("proposalVersion").is_some() { + json!({"proposalVersion":action["proposalVersion"],"effectDigest":action["effectDigest"]}) + } else { + json!({}) + }; + if action["operation"] == "reject_request" { + body["reason"] = json!("The task was revoked."); + } + send( + app, + Method::POST, + action["href"].as_str().unwrap(), + token, + Some(key), + action["ifMatch"].as_str(), + body, + ) + .await +} +pub(super) async fn counts(db: &TestDatabase) -> Vec { + let mut counts = Vec::new(); + for table in [ + "registry_revisions", + "registry_idempotency", + "registry_request_proposals", + "registry_request_decisions", + "registry_request_applications", + "registry_request_results", + "registry_request_task_authority", + ] { + counts.push( + db.admin + .query_one( + &format!("SELECT count(*) FROM registry_internal.{table}"), + &[], + ) + .await + .unwrap() + .get(0), + ); + } + counts +} diff --git a/crates/registry-casework/src/task_grants/tests.rs b/crates/registry-casework/src/task_grants/tests.rs new file mode 100644 index 0000000000..2046dab4a4 --- /dev/null +++ b/crates/registry-casework/src/task_grants/tests.rs @@ -0,0 +1,81 @@ +use super::*; +use registry_platform_config::{SecretProvider, SecretResolver}; + +#[tokio::test] +async fn template_versions_are_immutable_and_retirement_cannot_revive_existing_grants() { + let base = std::env::var("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL") + .expect("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL must name a disposable database"); + let schema = format!("task_templates_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .unwrap(); + let separator = if base.contains('?') { '&' } else { '?' }; + let scoped = format!("{base}{separator}options=-csearch_path%3D{schema}"); + let secret = format!("CASEWORK_TEMPLATE_TEST_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&secret, &scoped); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp").unwrap(); + let config = crate::DatabaseConfig { + runtime_url_ref: format!("secret:env/{secret}"), + migration_url_ref: format!("secret:env/{secret}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + let store = PostgresStore::connect_migration(&config, &secrets).unwrap(); + store.migrate().await.unwrap(); + std::env::remove_var(secret); + let template: TaskTemplate = serde_json::from_value(json!({ + "id":"summary", "version":"1", "label":"Prepare summary", "eligibleTeams":["team"], "eligibleProfiles":["staff"], + "source":"source", "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test","subject":"agent"}, "client":"agent-client", "resource":"urn:test:breg", + "purpose":"prepare-summary", "bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]}, + "subjects":{"person_reference":"person-reference"}, "lifetimeSeconds":900 + })).unwrap(); + store + .activate_task_templates(std::slice::from_ref(&template)) + .await + .unwrap(); + store + .activate_task_templates(std::slice::from_ref(&template)) + .await + .unwrap(); + let mut changed = template.clone(); + changed.purpose = "different-purpose".into(); + assert!(matches!( + store.activate_task_templates(&[changed]).await, + Err(StoreError::Configuration) + )); + let db = store.client().await.unwrap(); + let item = Uuid::new_v4(); + let grant = Uuid::new_v4(); + db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,revision,first_observed_at,updated_at) VALUES($1,'source','request','request-1','review','review-1',$2,'claimed','review',1,now(),now())", &[&item,&json!({"sourceRevision":"1","version":"1","generation":"1"})]).await.unwrap(); + db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff','key','hash',$3,now(),now()+interval '900 seconds')", &[&grant,&item,&json!({"template":template})]).await.unwrap(); + store.activate_task_templates(&[]).await.unwrap(); + let invalidated: bool = db + .query_one( + "SELECT invalidated_at IS NOT NULL FROM casework_task_grants WHERE grant_id=$1", + &[&grant], + ) + .await + .unwrap() + .get(0); + assert!(invalidated); + store.activate_task_templates(&[template]).await.unwrap(); + let invalidated: bool = db + .query_one( + "SELECT invalidated_at IS NOT NULL FROM casework_task_grants WHERE grant_id=$1", + &[&grant], + ) + .await + .unwrap() + .get(0); + assert!(invalidated, "reactivation only permits new approvals"); + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .unwrap(); +} diff --git a/crates/registry-casework/tests/assignment_postgres.rs b/crates/registry-casework/tests/assignment_postgres.rs index 9356b87ef1..d07bf2eacd 100644 --- a/crates/registry-casework/tests/assignment_postgres.rs +++ b/crates/registry-casework/tests/assignment_postgres.rs @@ -161,6 +161,7 @@ fn profile(id: &str, role: CaseworkRole, kinds: &[&str]) -> AccessProfile { fn project() -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: registry_casework_core::CASEWORK_API_VERSION.to_owned(), kind: registry_casework_core::CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/hosted_postgres.rs b/crates/registry-casework/tests/hosted_postgres.rs index e7e4cd80bd..671fd31c0a 100644 --- a/crates/registry-casework/tests/hosted_postgres.rs +++ b/crates/registry-casework/tests/hosted_postgres.rs @@ -49,6 +49,7 @@ fn profile(id: &str, role: CaseworkRole, kinds: &[&str]) -> AccessProfile { fn project(version: &str, outcomes: Vec) -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: registry_casework_core::CASEWORK_API_VERSION.to_owned(), kind: registry_casework_core::CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/human_identity_auth.rs b/crates/registry-casework/tests/human_identity_auth.rs index 4d89b2f138..dd39810342 100644 --- a/crates/registry-casework/tests/human_identity_auth.rs +++ b/crates/registry-casework/tests/human_identity_auth.rs @@ -289,6 +289,7 @@ fn token(idp: &MockIdp, scope: &str, asserted_kind: Option) -> String { fn project() -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: CASEWORK_API_VERSION.to_owned(), kind: CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/inbox_ordering_postgres.rs b/crates/registry-casework/tests/inbox_ordering_postgres.rs index 77b0b50f34..2d7646c1c5 100644 --- a/crates/registry-casework/tests/inbox_ordering_postgres.rs +++ b/crates/registry-casework/tests/inbox_ordering_postgres.rs @@ -157,6 +157,7 @@ fn binding() -> SourceBinding { fn project_with_inbox(inbox: InboxPolicy) -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: registry_casework_core::CASEWORK_API_VERSION.to_owned(), kind: registry_casework_core::CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/routing_postgres.rs b/crates/registry-casework/tests/routing_postgres.rs index 82ba57fcc4..2191960be9 100644 --- a/crates/registry-casework/tests/routing_postgres.rs +++ b/crates/registry-casework/tests/routing_postgres.rs @@ -148,6 +148,7 @@ fn metadata() -> RoutingSourceMetadata { fn routing_project() -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: registry_casework_core::CASEWORK_API_VERSION.to_owned(), kind: registry_casework_core::CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/service_visibility.rs b/crates/registry-casework/tests/service_visibility.rs index 0677e09df9..66839899de 100644 --- a/crates/registry-casework/tests/service_visibility.rs +++ b/crates/registry-casework/tests/service_visibility.rs @@ -703,6 +703,7 @@ async fn fixture_with_source(source: MockSource, inbox: InboxPolicy) -> Fixture fn project(inbox: InboxPolicy) -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: CASEWORK_API_VERSION.into(), kind: CASEWORK_KIND.into(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/source_retention_postgres.rs b/crates/registry-casework/tests/source_retention_postgres.rs index 29e9a7e644..91746fc41e 100644 --- a/crates/registry-casework/tests/source_retention_postgres.rs +++ b/crates/registry-casework/tests/source_retention_postgres.rs @@ -148,6 +148,7 @@ fn profile(id: &str, role: CaseworkRole) -> AccessProfile { fn project() -> CaseworkProject { CaseworkProject { + task_templates: Vec::new(), api_version: CASEWORK_API_VERSION.to_owned(), kind: CASEWORK_KIND.to_owned(), casework: CaseworkIdentity { diff --git a/crates/registry-casework/tests/task_grant_invalidation.rs b/crates/registry-casework/tests/task_grant_invalidation.rs new file mode 100644 index 0000000000..db58455f0b --- /dev/null +++ b/crates/registry-casework/tests/task_grant_invalidation.rs @@ -0,0 +1,147 @@ +//! Database-boundary regressions: transient writes inside a Directory transaction +//! do not revoke authority, but committed loss cannot be undone by later regain. +use registry_casework::{DatabaseConfig, PostgresStore}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use serde_json::json; +use tokio_postgres::{Client, NoTls}; +use uuid::Uuid; + +async fn fixture() -> (Client, Client, String) { + let base = std::env::var("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL") + .expect("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL must name a disposable database"); + let schema = format!("task_grants_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, NoTls).await.unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .unwrap(); + let separator = if base.contains('?') { '&' } else { '?' }; + let scoped = format!("{base}{separator}options=-csearch_path%3D{schema}"); + let secret = format!("CASEWORK_TASK_TEST_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&secret, &scoped); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp").unwrap(); + let config = DatabaseConfig { + runtime_url_ref: format!("secret:env/{secret}"), + migration_url_ref: format!("secret:env/{secret}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + PostgresStore::connect_migration(&config, &secrets) + .unwrap() + .migrate() + .await + .unwrap(); + std::env::remove_var(secret); + let (database, connection) = tokio_postgres::connect(&scoped, NoTls).await.unwrap(); + tokio::spawn(async move { connection.await.unwrap() }); + database.batch_execute("INSERT INTO casework_teams(team_id,revision) VALUES('team',1); INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team','https://issuer.test','human','staff'); INSERT INTO casework_queue_service(queue_id,team_id,revision) VALUES('review','team',1)").await.unwrap(); + (database, admin, schema) +} + +async fn grant(db: &Client) -> (Uuid, Uuid) { + let item = Uuid::new_v4(); + let grant = Uuid::new_v4(); + let binding = json!({"sourceRevision":"1","version":"proposal-1","generation":"source-1"}); + db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,holder_issuer,holder_subject,revision,first_observed_at,updated_at) VALUES($1,'source','request',$2,'review',$2,$3,'claimed','review','https://issuer.test','human',1,now(),now())", &[&item,&item.to_string(),&binding]).await.unwrap(); + let record = json!({"template":{"itemStates":["claimed","waiting_applicant"],"itemKinds":["request"],"source":"source","eligibleTeams":["team"]},"proposal":{"version":"proposal-1","generation":"source-1","integrity":null},"subjects":{"person_reference":"synthetic-person"}}); + db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff',$3,'synthetic-hash',$4,now(),now()+interval '900 seconds')", &[&grant,&item,&grant.to_string(),&record]).await.unwrap(); + (item, grant) +} + +async fn active(db: &Client, grant: Uuid) -> bool { + db.query_one( + "SELECT invalidated_at IS NULL FROM casework_task_grants WHERE grant_id=$1", + &[&grant], + ) + .await + .unwrap() + .get(0) +} + +#[tokio::test] +async fn directory_loss_is_permanent_but_atomic_replacement_preserves_grants() { + let (db, admin, schema) = fixture().await; + let (_, id) = grant(&db).await; + db.batch_execute("BEGIN; DELETE FROM casework_memberships; INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team','https://issuer.test','human','staff'); UPDATE casework_meta SET directory_revision=directory_revision+1; COMMIT").await.unwrap(); + assert!( + active(&db, id).await, + "unchanged atomic replacement preserves authority" + ); + db.batch_execute("BEGIN; DELETE FROM casework_memberships; UPDATE casework_meta SET directory_revision=directory_revision+1; COMMIT").await.unwrap(); + assert!( + !active(&db, id).await, + "committed loss invalidates without an assertion lookup" + ); + db.batch_execute("INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team','https://issuer.test','human','staff'); UPDATE casework_meta SET directory_revision=directory_revision+1").await.unwrap(); + assert!( + !active(&db, id).await, + "later regain cannot revive the grant" + ); + let count: i64 = db + .query_one( + "SELECT count(*) FROM casework_history WHERE kind='task_invalidated'", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(count, 1, "invalidation produces one durable event"); + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .unwrap(); +} + +#[tokio::test] +async fn holder_and_proposal_loss_are_permanent_but_revision_churn_is_allowed() { + let (db, admin, schema) = fixture().await; + let (item, id) = grant(&db).await; + db.execute("UPDATE casework_items SET binding=jsonb_set(binding,'{sourceRevision}','\"2\"'),revision=revision+1 WHERE item_id=$1", &[&item]).await.unwrap(); + assert!(active(&db, id).await); + db.execute( + "UPDATE casework_items SET holder_subject='other' WHERE item_id=$1", + &[&item], + ) + .await + .unwrap(); + db.execute( + "UPDATE casework_items SET holder_subject='human' WHERE item_id=$1", + &[&item], + ) + .await + .unwrap(); + assert!(!active(&db, id).await); + let (item, id) = grant(&db).await; + db.execute("UPDATE casework_items SET binding=jsonb_set(binding,'{version}','\"proposal-2\"') WHERE item_id=$1", &[&item]).await.unwrap(); + db.execute("UPDATE casework_items SET binding=jsonb_set(binding,'{version}','\"proposal-1\"') WHERE item_id=$1", &[&item]).await.unwrap(); + assert!(!active(&db, id).await); + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .unwrap(); +} + +#[tokio::test] +async fn item_erasure_removes_selector_bearing_grant_records() { + let (db, admin, schema) = fixture().await; + let (item, id) = grant(&db).await; + db.execute( + "UPDATE casework_items SET erased_at=now() WHERE item_id=$1", + &[&item], + ) + .await + .unwrap(); + assert!(db + .query_opt( + "SELECT record FROM casework_task_grants WHERE grant_id=$1", + &[&id] + ) + .await + .unwrap() + .is_none()); + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .unwrap(); +} diff --git a/crates/registry-caseworkctl/Cargo.toml b/crates/registry-caseworkctl/Cargo.toml index f80a775420..d3e242b2c2 100644 --- a/crates/registry-caseworkctl/Cargo.toml +++ b/crates/registry-caseworkctl/Cargo.toml @@ -25,6 +25,9 @@ p256.workspace = true rcgen.workspace = true registry-platform-buildinfo.workspace = true registry-platform-config.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httputil.workspace = true +registry-thunderid-tooling.workspace = true registry-casework-core.workspace = true registry-casework.workspace = true reqwest.workspace = true diff --git a/crates/registry-caseworkctl/src/dev/config.rs b/crates/registry-caseworkctl/src/dev/config.rs index 896400f42d..193b1cb4bf 100644 --- a/crates/registry-caseworkctl/src/dev/config.rs +++ b/crates/registry-caseworkctl/src/dev/config.rs @@ -18,8 +18,8 @@ use std::{ use zeroize::Zeroizing; /// The identity claim a Casework human profile must carry, and the value the -/// generated operator configuration requires. Mint copies these verbatim from -/// the clients file into every token it issues for that client. +/// generated operator configuration requires. Only explicitly declared local +/// teaching fixtures may carry a human marker on a machine-issued token. pub(super) const HUMAN_CLAIM: &str = "registry_actor_kind"; pub(super) const HUMAN_VALUE: &str = "human"; const RESERVED_ACCESS_TOKEN_CLAIMS: [&str; 9] = [ @@ -71,8 +71,7 @@ pub(super) fn identifier(value: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') } -// Keep these predicates aligned with registry-mint's client authorization contract: -// `dev` writes these scopes and claims into Mint registrations before requesting tokens. +// Apply OAuth scope-token syntax before rendering exact issuer permissions. fn valid_scope_token(value: &str) -> bool { !value.is_empty() && value.bytes().all(|byte| { @@ -186,25 +185,6 @@ pub(super) struct Bound<'a> { /// client's principal. Refusing here, before any container or service starts, /// tells the author which binding is missing while the fix is one edit away. pub(super) fn bind<'a>(clients: &'a Clients, project: &CaseworkProject) -> Result>> { - bind_with_principal(clients, project, principal) -} - -/// Resolve clients that authenticate through a BReg development session's -/// Mint. Its automatic subject namespace differs from standalone Casework. -pub(super) fn bind_source_backed<'a>( - clients: &'a Clients, - project: &CaseworkProject, -) -> Result>> { - bind_with_principal(clients, project, |client_id| { - format!("urn:breg:dev:{client_id}") - }) -} - -fn bind_with_principal<'a>( - clients: &'a Clients, - project: &CaseworkProject, - mint_principal: impl Fn(&str) -> String, -) -> Result>> { let mut bound = Vec::with_capacity(clients.clients.len()); for client in &clients.clients { let profile = project @@ -244,7 +224,7 @@ fn bind_with_principal<'a>( _ => (), } let principal = if profile.principal_claim == "sub" { - mint_principal(&client.id) + principal(&client.id) } else { client .claims @@ -339,10 +319,10 @@ fn bind_with_principal<'a>( Ok(bound) } -/// The Mint principal a local client speaks as. It is a local teaching +/// The native issuer principal a local client speaks as. It is a local teaching /// identity, never a deployment identity. -pub(crate) fn principal(client_id: &str) -> String { - format!("urn:casework:dev:{client_id}") +pub(super) fn principal(client_id: &str) -> String { + registry_thunderid_tooling::local::agent_id("casework-local", client_id) } pub(super) fn hex_lower(bytes: &[u8]) -> String { @@ -386,89 +366,39 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( "credentials", "secrets", "tls", - "mint", + "issuer", "logs", "audit", "database", ] { private::directory(&root.join(directory))?; } - private::directory(&root.join("mint/clients"))?; - private::directory(&root.join("mint/audit"))?; - // A source-backed session borrows the registry session's Mint: its - // issuer keys, every client pair, and the operator configuration naming - // them arrive when the sources are bound, after this staging completes. - let borrowed = !state.sources.is_empty(); - for client in &clients.clients { - if borrowed { - private::directory(&root.join("credentials").join(&client.id))?; - } - } - if borrowed { - return prepare_database(root, state); - } - let mint_public = keypair(&root.join("credentials/issuer"))?; - let mint_public_filename = format!( - "{}.jwk.json", - mint_public["kid"] - .as_str() - .context("generated issuer key ID missing")? - ); - private::create( - &root.join("credentials/issuer").join(&mint_public_filename), - &serde_json::to_vec(&mint_public)?, - )?; + let mut local_clients = Vec::new(); for client in &clients.clients { let directory = root.join("credentials").join(&client.id); let public = keypair(&directory)?; private::create(&directory.join("client-id"), client.id.as_bytes())?; - write_yaml( - &root - .join("mint/clients") - .join(format!("{}.yaml", client.id)), - &json!({ - "clientId":client.id,"principal":principal(&client.id), - "authorization":{"scopes":client.scopes,"claims":client.claims},"keys":[public] - }), - )?; + local_clients.push(registry_thunderid_tooling::local::LocalClient { + client_id: client.id.clone(), + public_jwks: serde_json::to_string(&json!({"keys":[public]}))?, + claims: client.claims.clone(), + scopes: client.scopes.clone(), + allow_human_fixture: true, + }); } - private::create( - &root.join("secrets/mint-audit-key"), - hex_secret()?.as_bytes(), - )?; - private::create( - &root.join("secrets/mint-jwks"), - &serde_json::to_vec(&json!({"keys":[mint_public]}))?, - )?; - prepare_database(root, state)?; - let final_root = state.root(); - let mint_origin = state.mint_origin(); - write_yaml( - &root.join("mint/mint.yaml"), - &json!({ - "version":1,"validationMode":"supervised-local-development","issuer":mint_origin, - "listener":{"address":"127.0.0.1","port":state.mint_port}, - "signing":{"algorithm":"ES256","activePublicJwkFile":final_root.join("credentials/issuer").join(mint_public_filename),"publishedPublicJwkFiles":[],"revokedKeyIds":[]}, - "signer":{"kind":"local-jwk","privateKeyRef":"secret:file/assertion-key.jwk"}, - "secretProviders":{"file":{"root":final_root.join("credentials/issuer")}}, - "audit":{"path":"audit/mint.jsonl","maximumFileBytes":10485760,"hashKeyRef":"secret:file/mint-audit-key","hashKeyVersion":1}, - "accessTokens":{"audiences":[state.audience()],"lifetimeSeconds":300}, - "clientAssertion":{"audience":format!("{mint_origin}/token"),"maximumLifetimeSeconds":120,"algorithms":["ES256"]}, - "clients":{"directory":"clients"} - }), + // Stable teaching subjects are qualified by the exact local issuer URL. + // The container label separately binds the randomly owned dev session. + let description = registry_thunderid_tooling::local::local_description( + registry_thunderid_tooling::description::SessionIdentity { + label: format!("casework-dev-{}", state.owner), + id: "casework-local".into(), + }, + state.issuer_port, + root.join("issuer"), + state.audience(), + local_clients, )?; - // One secret root serves Mint signing and audit; no cross-directory secret references. - private::create( - &root.join("credentials/issuer/mint-audit-key"), - &private::read(&root.join("secrets/mint-audit-key"), 128)?, - )?; - write_yaml(&root.join("operator.yaml"), &operator(state)?)?; - Ok(()) -} - -/// Stage the audit key, the database bootstrap material, and the TLS -/// material every session needs, borrowed issuer or not. -fn prepare_database(root: &Path, state: &State) -> Result<()> { + registry_thunderid_tooling::render::render(&description)?; private::create( &root.join("secrets/casework-audit-key"), hex_secret()?.as_bytes(), @@ -533,6 +463,7 @@ fn prepare_database(root: &Path, state: &State) -> Result<()> { Zeroizing::new(pem("PRIVATE KEY", &server_key.serialize_der())).as_bytes(), )?; private::create(&root.join("database/pg_hba.conf"), b"local all all trust\nhostnossl all all 0.0.0.0/0 reject\nhostnossl all all ::/0 reject\nhostssl all all 0.0.0.0/0 scram-sha-256\nhostssl all all ::/0 scram-sha-256\n")?; + write_yaml(&root.join("operator.yaml"), &operator(state))?; Ok(()) } @@ -541,34 +472,9 @@ fn prepare_database(root: &Path, state: &State) -> Result<()> { /// It binds the authored `casework.yaml` the reader edits, not a copy, so /// `caseworkctl doctor --runtime-config ` reports on the same /// policy the reader's `caseworkctl check` reads. -/// -/// A source-backed session names the borrowed registry issuer and binds each -/// source to the reader pair its registry session exported. -pub(super) fn operator(state: &State) -> Result { +pub(super) fn operator(state: &State) -> Value { let root = state.root(); - let mut sources = serde_json::Map::new(); - for (id, source) in &state.sources { - let binding = source - .binding - .as_ref() - .with_context(|| format!("source {id} is not bound to a registry session yet"))?; - sources.insert( - id.clone(), - json!({ - "baseUrl": binding.breg_url, - "readerProfile": "casework-reader", - "tokenEndpoint": binding.token_endpoint, - "clientIdRef": format!("secret:file/{id}-reader-client-id"), - "clientAssertionKeyRef": format!("secret:file/{id}-reader-assertion-key.jwk"), - "webhookSecretRef": format!("secret:file/{id}-webhook-key"), - "eventSource": binding.event_source, - // The registry session delivers no events here, so a local - // reader sees accepted requests through reconciliation alone. - "reconciliationIntervalMilliseconds": 5000 - }), - ); - } - Ok(json!({ + json!({ "apiVersion": registry_casework::RUNTIME_CONFIG_API_VERSION, "kind": registry_casework::RUNTIME_CONFIG_KIND, "package": {"root": state.project}, @@ -584,21 +490,21 @@ pub(super) fn operator(state: &State) -> Result { "trustedRootCertificateRef": "secret:file/database-root.pem" }, "authentication": {"oidc": { - "issuer": state.issuer(), + "issuer": state.issuer_origin(), "audience": state.audience(), - // Mint emits one space-delimited `scope` claim. + // The local issuer emits one space-delimited scope claim. "scopeClaim": "scope", // The local issuer's keys are generated beside this file, so the // runtime reads them directly instead of racing discovery. - "jwksSource": {"kind": "static", "documentRef": "secret:file/mint-jwks"}, + "jwksSource": {"kind": "static", "documentRef": "secret:file/issuer-jwks"}, "humanIdentity": {"claim": HUMAN_CLAIM, "value": HUMAN_VALUE} }}, "audit": { "path": root.join("audit/casework.ndjson"), "hashKeyRef": "secret:file/casework-audit-key" }, - "sources": sources - })) + "sources": {} + }) } pub(super) fn write_yaml(path: &Path, value: &Value) -> Result<()> { diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 30f050d318..1d45171c10 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -98,11 +98,8 @@ enum DevAction { /// Start or reuse the project's retained local database and services. /// /// A resident supervisor owns this project's PostgreSQL container plus its - /// local Registry Mint and Casework children. A project that declares - /// Base Registry Engine sources starts no Mint of its own: pass - /// --source-project and the session borrows the issuer, audience, and - /// clients of the `bregctl dev` session serving each source. The database - /// runs the pinned image + /// local pinned issuer container and Casework child. The database runs the pinned + /// image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, /// which the supervisor pulls on the first start. Each supervised /// prerequisite command may run for 120 seconds, and the database and each @@ -114,6 +111,16 @@ enum DevAction { Stop(StopArgs), /// Print the bounded retained runtime journal. Events(EventsArgs), + /// Write a fresh bearer header for a registered local teaching client. + Token(TokenArgs), +} + +#[derive(Debug, Args)] +struct TokenArgs { + #[arg(value_name = "CLIENT")] + client: String, + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, } #[derive(Debug, Args)] @@ -128,26 +135,16 @@ struct StartArgs { /// Casework loopback port on first start (default 8092; retained for restarts). #[arg(long, env = "CASEWORKCTL_DEV_CASEWORK_PORT")] casework_port: Option, - /// Local Mint loopback port on first start (default 8093; retained for restarts). - #[arg(long, env = "CASEWORKCTL_DEV_MINT_PORT")] - mint_port: Option, + /// Local issuer loopback port on first start (default 8093; retained for restarts). + #[arg(long, env = "CASEWORKCTL_DEV_ISSUER_PORT")] + issuer_port: Option, /// PostgreSQL loopback port on first start (default 55433; retained for restarts). #[arg(long, env = "CASEWORKCTL_DEV_DATABASE_PORT")] database_port: Option, - /// Base Registry Engine project whose running `bregctl dev` session serves - /// a declared source, as ID=PATH, or PATH alone when the project declares - /// one source (retained for restarts). The session borrows that registry's - /// local Mint as its issuer and exports every local client from it. - #[arg(long, value_name = "[ID=]PATH")] - source_project: Vec, #[arg(long, hide = true, env = "CASEWORK_BIN")] casework_bin: Option, #[arg(long, hide = true)] - mint_bin: Option, - #[arg(long, hide = true)] docker_bin: Option, - #[arg(long, hide = true, env = "BREGCTL_BIN")] - bregctl_bin: Option, } #[derive(Debug, Args)] @@ -176,8 +173,6 @@ pub struct SupervisorArgs { #[arg(long)] casework_bin: PathBuf, #[arg(long)] - mint_bin: PathBuf, - #[arg(long)] docker_bin: PathBuf, } @@ -198,7 +193,7 @@ struct State { owner: String, status: Status, casework_port: u16, - mint_port: u16, + issuer_port: u16, database_port: u16, clients_file: PathBuf, source_digest: String, @@ -225,49 +220,6 @@ struct State { /// to the owner. #[serde(default)] failure: Option, - /// Declared sources and the retained `bregctl dev` project serving each, - /// by source identifier. A session with sources runs no Mint of its own: - /// it borrows the first registry's issuer, so every human token a reader - /// mints carries both the Casework and the registry authority. - #[serde(default)] - sources: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SourceSession { - /// The Base Registry Engine project whose `bregctl dev` session serves this source. - project: PathBuf, - /// What the registry session reported when it last exported the clients, - /// absent until the first export completes. - #[serde(default)] - binding: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct SourceBinding { - breg_url: String, - token_endpoint: String, - audience: String, - event_source: String, -} - -struct StagedCredentialPair { - client_id: PathBuf, - assertion_key: PathBuf, -} - -struct StagedSourceExport { - binding: SourceBinding, - reader: StagedCredentialPair, - clients: BTreeMap, -} - -struct StagedSourceExports { - /// Own the scratch tree until every staged credential has been published. - _scratch: tempfile::TempDir, - sources: BTreeMap, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -322,47 +274,11 @@ impl State { fn casework_origin(&self) -> String { format!("http://127.0.0.1:{}", self.casework_port) } - fn mint_origin(&self) -> String { - format!("http://127.0.0.1:{}", self.mint_port) - } - /// The registry session whose Mint this session borrows, once bound. - fn borrowed(&self) -> Option<&SourceBinding> { - self.sources - .values() - .find_map(|source| source.binding.as_ref()) - } - fn token_endpoint(&self) -> String { - match self.borrowed() { - Some(binding) => binding.token_endpoint.clone(), - None => format!("{}/token", self.mint_origin()), - } - } - /// The issuer the runtime trusts: the borrowed registry Mint, whose token - /// endpoint is its origin plus `/token`, else this session's own Mint. - fn issuer(&self) -> String { - match self.borrowed() { - Some(binding) => binding - .token_endpoint - .strip_suffix("/token") - .unwrap_or(&binding.token_endpoint) - .to_owned(), - None => self.mint_origin(), - } + fn issuer_origin(&self) -> String { + format!("http://127.0.0.1:{}", self.issuer_port) } fn audience(&self) -> String { - match self.borrowed() { - Some(binding) => binding.audience.clone(), - None => format!("urn:casework:dev:{}", self.owner), - } - } - /// The loopback ports this session's own services listen on. A session - /// with sources borrows the registry's Mint and starts none of its own. - fn listening_ports(&self) -> Vec { - if self.sources.is_empty() { - vec![self.casework_port, self.mint_port] - } else { - vec![self.casework_port] - } + format!("urn:casework:dev:{}", self.owner) } fn administrator(&self) -> Result<&ReportedClient> { self.clients @@ -380,10 +296,9 @@ impl State { let root = self.root(); json!({"ok":true,"command":"dev","status":self.status,"project":self.project, "stateFile":root.join("state.json"),"operatorConfig":root.join("operator.yaml"), - "caseworkUrl":self.casework_origin(),"tokenEndpoint":self.token_endpoint(), + "caseworkUrl":self.casework_origin(),"tokenEndpoint":format!("{}/oauth2/token",self.issuer_origin()), + "issuer":self.issuer_origin(),"clientAssertionAudience":self.issuer_origin(),"resource":self.audience(), "audience":self.audience(),"journal":root.join("logs/casework.log"), - "sources":self.sources.iter().map(|(id,source)|(id.clone(),json!({"project":source.project, - "bregUrl":source.binding.as_ref().map(|binding|binding.breg_url.clone())}))).collect::>(), "clients":self.clients.iter().map(|client|json!({"id":client.id,"profile":client.profile,"role":client.role, "clientIdFile":root.join("credentials").join(&client.id).join("client-id"), "assertionKeyFile":root.join("credentials").join(&client.id).join("assertion-key.jwk")})).collect::>(), @@ -395,11 +310,38 @@ pub(crate) fn run(args: DevArgs) -> Result { match args.action { Some(DevAction::Stop(args)) => stop(&args.project, args.remove, args.docker_bin.as_deref()), Some(DevAction::Events(args)) => events(&args.project), + Some(DevAction::Token(args)) => fresh_token(&args.project, &args.client), Some(DevAction::Start(args)) => start(args), None => start(args.start), } } +fn fresh_token(project_path: &Path, client: &str) -> Result { + if !config::identifier(client) { + bail!("a registered bounded local client ID is required"); + } + let project = project(project_path)?; + private::check(&project.join(".casework"), true)?; + let root = project.join(".casework/dev"); + let state = read_state(&root)?; + if !matches!(state.status, Status::Ready) + || !control(&root, "status").is_ok_and(|status| status == "ready") + { + bail!("the local development session must be ready before requesting a token"); + } + token(&state, client, &AtomicBool::new(false))?; + let credential = Zeroizing::new(private::read( + &root.join("secrets").join(format!("{client}-token")), + 65536, + )?); + let mut header = Zeroizing::new(b"Authorization: Bearer ".to_vec()); + header.extend_from_slice(&credential); + header.push(b'\n'); + let output = root.join("secrets").join(format!("{client}.header")); + private::replace(&output, &header)?; + Ok(json!({"ok":true,"command":"dev token","headerFile":output})) +} + fn project(path: &Path) -> Result { if !fs::symlink_metadata(path)?.is_dir() { bail!("project must be an ordinary existing directory"); @@ -443,11 +385,16 @@ fn clients_file( fn read_state(root: &Path) -> Result { private::check(root, true)?; - let state: State = serde_json::from_slice(&private::read(&root.join("state.json"), MAX_BYTES)?) - .map_err(|_| { - anyhow::anyhow!("retained dev state is invalid; preserve it for inspection") - })?; - if state.version != 1 + let bytes = private::read(&root.join("state.json"), MAX_BYTES)?; + let shape: Value = serde_json::from_slice(&bytes) + .context("retained dev state is invalid; preserve it for inspection")?; + if shape["version"] == 1 || shape.get("mint_port").is_some() || shape.get("mintPort").is_some() + { + bail!("this retained session uses Mint; stop it with the matching older CLI and keep its state, then create a separate project directory for the current issuer"); + } + let state: State = serde_json::from_value(shape) + .context("retained dev state is invalid; preserve it for inspection")?; + if state.version != 2 || state.root() != root || uuid::Uuid::parse_str(&state.owner).is_err() || state.directory_revision < 0 @@ -458,15 +405,15 @@ fn read_state(root: &Path) -> Result { { bail!("retained dev state ownership is invalid; no resources were changed"); } - ports(state.casework_port, state.mint_port, state.database_port)?; + ports(state.casework_port, state.issuer_port, state.database_port)?; Ok(state) } -fn ports(casework: u16, mint: u16, database: u16) -> Result<()> { +fn ports(casework: u16, issuer: u16, database: u16) -> Result<()> { if casework == 0 - || mint == 0 + || issuer == 0 || database == 0 - || BTreeSet::from([casework, mint, database]).len() != 3 + || BTreeSet::from([casework, issuer, database]).len() != 3 { bail!("three distinct nonzero loopback ports are required"); } @@ -489,15 +436,12 @@ fn bounded(path: &Path, label: &str) -> Result> { } #[derive(Debug)] -/// Everything the retained session is pinned to: the authored policy and -/// imported source descriptions the supervised runtime serves, and the local -/// clients bound to them. +/// Everything the retained session is pinned to: the authored policy the +/// supervised runtime serves, and the local clients bound to it. struct Captured { clients: Clients, digest: String, reported: Vec, - /// The registry project serving each declared source. - sources: BTreeMap, } /// Catch terminal interruption while the foreground command waits for its @@ -535,25 +479,13 @@ impl Drop for StartInterruption { } } -fn capture( - project: &Path, - client_bytes: &[u8], - source_project_args: &[String], - retained: &BTreeMap, -) -> Result { +fn capture(project: &Path, client_bytes: &[u8]) -> Result { let policy = crate::project::load_and_check_policy(project)?; - let declared: Vec = policy - .sources - .iter() - .map(|source| source.id.clone()) - .collect(); - let sources = source_projects(&declared, source_project_args, retained)?; + if !policy.sources.is_empty() { + bail!("caseworkctl dev serves a project with no declared sources, because every source binding needs a running source system and its own reader credential. Run this project against a deployed Casework runtime, or start with the standalone-decision template"); + } let clients = config::clients(client_bytes)?; - let bound = if sources.is_empty() { - config::bind(&clients, &policy) - } else { - config::bind_source_backed(&clients, &policy) - }?; + let bound = config::bind(&clients, &policy)?; let reported = bound .iter() .map(|entry| ReportedClient { @@ -570,386 +502,13 @@ fn capture( hasher.update((bytes.len() as u64).to_be_bytes()); hasher.update(bytes); } - for source in &policy.sources { - let path = crate::project::project_input_path(project, &source.description)?; - let description = bounded(&path, &format!("source description {}", source.id))?; - hasher.update((description.len() as u64).to_be_bytes()); - hasher.update(description); - } - // Records retained for one registry project must not be served over - // another: the registry each source binds is part of the pinned inputs. - for (id, registry) in &sources { - for bytes in [id.as_bytes(), registry.as_os_str().as_encoded_bytes()] { - hasher.update((bytes.len() as u64).to_be_bytes()); - hasher.update(bytes); - } - } Ok(Captured { clients, digest: config::hex_lower(&hasher.finalize()), reported, - sources, - }) -} - -/// The registry project serving each declared source: the one named with -/// `--source-project`, else the one the retained session started with. -fn source_projects( - declared: &[String], - arguments: &[String], - retained: &BTreeMap, -) -> Result> { - let mut sources = BTreeMap::new(); - for argument in arguments { - let (id, path) = match argument.split_once('=') { - Some((id, path)) if !id.is_empty() && !path.contains('=') => (id.to_owned(), path), - Some(_) => bail!("--source-project takes ID=PATH, or PATH alone"), - None => match declared { - [only] => (only.clone(), argument.as_str()), - _ => bail!( - "this project declares the sources {}; name the one each registry project serves as --source-project ID=PATH", - declared.join(", ") - ), - }, - }; - if !declared.contains(&id) { - bail!( - "this project declares no source named {id}; its sources are {}", - declared.join(", ") - ); - } - let registry = project(Path::new(path)).with_context(|| { - format!("the registry project for source {id} must be an existing directory") - })?; - if sources.insert(id.clone(), registry).is_some() { - bail!("--source-project names the source {id} more than once"); - } - } - for id in declared { - if sources.contains_key(id) { - continue; - } - match retained.get(id) { - Some(source) => { - sources.insert(id.clone(), source.project.clone()); - } - None => bail!( - "source {id} needs the Base Registry Engine project that bregctl dev serves for it; pass --source-project {id}=PATH" - ), - } - } - Ok(sources) -} - -/// Bind every declared source to the registry session serving it: export the -/// reader and every local client from that session, confirm the registry is -/// answering, copy its issuer's published keys, and write the operator -/// configuration that names them all. Runs on every start, so the retained -/// copies follow the registry session they were exported from. -fn bind_sources(bregctl: &Path, state: &mut State, clients: &Clients) -> Result<()> { - export_sources(bregctl, state, clients)?; - for (id, source) in &state.sources { - let binding = source - .binding - .as_ref() - .with_context(|| format!("source {id} has no registry binding after export"))?; - let ready = http_with_timeout( - "GET", - &format!("{}/ready", binding.breg_url), - None, - &[], - None, - HTTP_TIMEOUT, - None, - ); - if !matches!(ready, Ok((200, _))) { - bail!( - "the registry serving source {id} is not answering at {}; start it with bregctl dev {} first", - binding.breg_url, - source.project.display() - ); - } - } - let root = state.root(); - private::replace( - &root.join("secrets/mint-jwks"), - &issuer_keys(&state.issuer())?, - )?; - private::replace( - &root.join("operator.yaml"), - serde_norway::to_string(&config::operator(state)?)?.as_bytes(), - ) -} - -/// Export the Casework reader and every local client from each source's -/// registry session into private scratch space. Only after every report names -/// one shared issuer and every source exports the same Casework client pairs -/// are the retained copies replaced. Source readers remain source-specific. -fn export_sources(bregctl: &Path, state: &mut State, clients: &Clients) -> Result<()> { - let root = state.root(); - let scratch = tempfile::Builder::new() - .prefix(".source-export-") - .tempdir_in(&root) - .context("creating private source export directory")?; - fs::set_permissions(scratch.path(), fs::Permissions::from_mode(0o700))?; - private::check(scratch.path(), true)?; - let mut staged = BTreeMap::new(); - for (index, (id, source)) in state.sources.iter().enumerate() { - let stage = scratch.path().join(format!("source-{index}")); - private::directory(&stage)?; - let event_source = event_source(&source.project)?; - let reader = StagedCredentialPair { - client_id: stage.join("reader-client-id"), - assertion_key: stage.join("reader-assertion-key.jwk"), - }; - let report = export_client( - bregctl, - &root, - &source.project, - "casework-reader", - &reader.client_id, - &reader.assertion_key, - )?; - let binding = source_binding(&report, event_source.clone())?; - let mut exported_clients = BTreeMap::new(); - for (client_index, client) in clients.clients.iter().enumerate() { - let pair = StagedCredentialPair { - client_id: stage.join(format!("client-{client_index}-id")), - assertion_key: stage.join(format!("client-{client_index}-assertion-key.jwk")), - }; - let report = export_client( - bregctl, - &root, - &source.project, - &client.id, - &pair.client_id, - &pair.assertion_key, - )?; - if source_binding(&report, event_source.clone())? != binding { - bail!( - "the registry session serving source {id} changed while exporting Casework client {}; retry after its local session is stable", - client.id - ); - } - exported_clients.insert(client.id.clone(), pair); - } - staged.insert( - id.clone(), - StagedSourceExport { - binding, - reader, - clients: exported_clients, - }, - ); - } - let staged = StagedSourceExports { - _scratch: scratch, - sources: staged, - }; - // One issuer serves the session, so every registry must share a local Mint. - let mut issuers = staged - .sources - .values() - .map(|source| (&source.binding.token_endpoint, &source.binding.audience)); - let first = issuers.next(); - if issuers.any(|issuer| Some(issuer) != first) { - bail!("the registry sessions serving this project's sources use different local Mints; a local session borrows exactly one issuer"); - } - let mut sources = staged.sources.iter(); - if let Some((first_id, first)) = sources.next() { - for (id, source) in sources { - for client in &clients.clients { - let first_pair = &first.clients[&client.id]; - let pair = &source.clients[&client.id]; - if !credential_pairs_match(first_pair, pair)? { - bail!( - "the registry sessions serving sources {first_id} and {id} export different credentials for Casework client {}; every source must share one local Mint client registration", - client.id - ); - } - } - } - } - let secrets = root.join("secrets"); - for (id, source) in &staged.sources { - publish_credential_pair( - &source.reader, - &secrets.join(format!("{id}-reader-client-id")), - &secrets.join(format!("{id}-reader-assertion-key.jwk")), - )?; - // The registry session routes its events to its own receiver; this - // key authenticates the receiver Casework publishes regardless. - let webhook = secrets.join(format!("{id}-webhook-key")); - if !webhook.exists() { - private::create(&webhook, config::hex_secret()?.as_bytes())?; - } - } - if let Some(source) = staged.sources.values().next() { - for client in &clients.clients { - let directory = root.join("credentials").join(&client.id); - publish_credential_pair( - &source.clients[&client.id], - &directory.join("client-id"), - &directory.join("assertion-key.jwk"), - )?; - } - } - for (id, source_export) in staged.sources { - if let Some(source) = state.sources.get_mut(&id) { - source.binding = Some(source_export.binding); - } - } - state.save() -} - -fn credential_pairs_match( - first: &StagedCredentialPair, - other: &StagedCredentialPair, -) -> Result { - let first_id = Zeroizing::new(private::read(&first.client_id, MAX_BYTES)?); - let other_id = Zeroizing::new(private::read(&other.client_id, MAX_BYTES)?); - let first_key = Zeroizing::new(private::read(&first.assertion_key, MAX_BYTES)?); - let other_key = Zeroizing::new(private::read(&other.assertion_key, MAX_BYTES)?); - Ok(*first_id == *other_id && *first_key == *other_key) -} - -fn publish_credential_pair( - staged: &StagedCredentialPair, - client_id: &Path, - assertion_key: &Path, -) -> Result<()> { - let id = Zeroizing::new(private::read(&staged.client_id, MAX_BYTES)?); - let key = Zeroizing::new(private::read(&staged.assertion_key, MAX_BYTES)?); - private::replace(client_id, &id)?; - private::replace(assertion_key, &key) -} - -fn source_binding(report: &Value, event_source: String) -> Result { - let text = |field: &str| -> Result { - report[field] - .as_str() - .map(str::to_owned) - .with_context(|| format!("bregctl dev export-client reported no {field}")) - }; - Ok(SourceBinding { - breg_url: text("bregUrl")?, - token_endpoint: text("tokenEndpoint")?, - audience: text("audience")?, - event_source, }) } -/// Refuse an idempotent start when a registry project now names a different -/// retained BReg session. Its Mint audience and keys are part of the running -/// Casework process, so replacing them requires an explicit Casework restart. -fn require_active_source_bindings(bregctl: &Path, state: &State) -> Result<()> { - let scratch = tempfile::Builder::new() - .prefix(".active-source-check-") - .tempdir_in(state.root()) - .context("creating private active-source check directory")?; - fs::set_permissions(scratch.path(), fs::Permissions::from_mode(0o700))?; - private::check(scratch.path(), true)?; - for (id, source) in &state.sources { - let report = export_client( - bregctl, - &state.root(), - &source.project, - "casework-reader", - &scratch.path().join(format!("{id}-client-id")), - &scratch.path().join(format!("{id}-assertion-key.jwk")), - )?; - let current = source_binding(&report, event_source(&source.project)?)?; - if source.binding.as_ref() != Some(¤t) { - bail!( - "the active local development session still uses an earlier BReg session for source {id}; stop Casework and start it again to bind the current registry issuer and credentials" - ); - } - } - Ok(()) -} - -/// Export one client pair from a registry session into prepared scratch paths. -fn export_client( - bregctl: &Path, - root: &Path, - registry: &Path, - client: &str, - client_id_file: &Path, - assertion_key_file: &Path, -) -> Result { - let bytes = command( - Command::new(bregctl) - .args(["--format", "json", "dev", "export-client"]) - .arg(registry) - .arg("--client") - .arg(client) - .arg("--client-id-file") - .arg(client_id_file) - .arg("--assertion-key-file") - .arg(assertion_key_file), - root, - "export-client", - None, - )?; - let report: Value = serde_json::from_slice(&bytes) - .context("bregctl dev export-client returned no JSON report")?; - if report["ok"] != true { - bail!("bregctl dev export-client did not report success for client {client}"); - } - if report["client"] != client { - bail!("bregctl dev export-client reported a different client than {client}"); - } - for path in [client_id_file, assertion_key_file] { - private::check(path, false)?; - } - Ok(report) -} - -/// The event source a registry names for itself, from its authored registry.yaml. -fn event_source(registry: &Path) -> Result { - let authored: Value = - serde_norway::from_slice(&bounded(®istry.join("registry.yaml"), "registry.yaml")?) - .context("registry.yaml must parse")?; - let id = authored["registry"]["id"] - .as_str() - .context("registry.yaml declares no registry.id")?; - let instance = authored["package"]["instanceId"] - .as_str() - .context("registry.yaml declares no package.instanceId")?; - Ok(format!( - "urn:registrystack:registry:{id}:instance:{instance}" - )) -} - -/// The published keys of the borrowed issuer, located through its discovery -/// document and accepted only from the issuer's own origin. -fn issuer_keys(issuer: &str) -> Result> { - let (status, discovery) = http_with_timeout( - "GET", - &format!("{issuer}/.well-known/openid-configuration"), - None, - &[], - None, - HTTP_TIMEOUT, - None, - ) - .with_context(|| format!("the registry issuer at {issuer} is not answering"))?; - if status != 200 || discovery["issuer"] != issuer { - bail!("the registry issuer at {issuer} publishes no discovery document for itself"); - } - let jwks_uri = discovery["jwks_uri"] - .as_str() - .filter(|uri| uri.starts_with(&format!("{issuer}/"))) - .with_context(|| { - format!("the registry issuer at {issuer} names no key document of its own") - })?; - let (status, keys) = http_with_timeout("GET", jwks_uri, None, &[], None, HTTP_TIMEOUT, None)?; - if status != 200 || keys["keys"].as_array().is_none_or(|keys| keys.is_empty()) { - bail!("the registry issuer at {issuer} publishes no keys"); - } - Ok(serde_json::to_vec(&keys)?) -} - fn start(args: StartArgs) -> Result { let project = project(&args.project)?; let parent = parent_directory(&project)?; @@ -962,21 +521,11 @@ fn start(args: StartArgs) -> Result { }; let clients_file = clients_file(args.clients_file.as_deref(), existing.as_ref(), &project)?; let client_bytes = bounded(&clients_file, "clients file")?; - let no_sources = BTreeMap::new(); - let retained_sources = existing - .as_ref() - .map_or(&no_sources, |state| &state.sources); let Captured { clients, digest, reported, - sources, - } = capture( - &project, - &client_bytes, - &args.source_project, - retained_sources, - )?; + } = capture(&project, &client_bytes)?; // The source pin protects the records a session retains. Once `dev stop // --remove` has discarded them, changed inputs start a fresh session on // the ports and clients file the previous one used. @@ -985,7 +534,7 @@ fn start(args: StartArgs) -> Result { Some(state) if digest != state.source_digest || args.casework_port.is_some_and(|p| p != state.casework_port) - || args.mint_port.is_some_and(|p| p != state.mint_port) + || args.issuer_port.is_some_and(|p| p != state.issuer_port) || args.database_port.is_some_and(|p| p != state.database_port) => { let _supervisor_lock = completed_supervisor_lock(&root, &state.status)?; @@ -1002,10 +551,6 @@ fn start(args: StartArgs) -> Result { if args.clients_file.is_some() && state.clients_file != clients_file { bail!("the active local development session still uses {}; stop it before selecting a different --clients-file path", state.clients_file.display()); } - if !state.sources.is_empty() { - let bregctl = executable("bregctl", args.bregctl_bin.as_deref())?; - require_active_source_bindings(&bregctl, &state)?; - } return Ok(state.report()); } // A live owner lock is conclusive even when its control socket is not ready. @@ -1020,7 +565,7 @@ fn start(args: StartArgs) -> Result { } else { let previous = previous.as_ref(); let state = State { - version: 1, + version: 2, project: project.clone(), owner: uuid::Uuid::new_v4().to_string(), status: Status::Stopped, @@ -1028,9 +573,9 @@ fn start(args: StartArgs) -> Result { .casework_port .or(previous.map(|s| s.casework_port)) .unwrap_or(8092), - mint_port: args - .mint_port - .or(previous.map(|s| s.mint_port)) + issuer_port: args + .issuer_port + .or(previous.map(|s| s.issuer_port)) .unwrap_or(8093), database_port: args .database_port @@ -1048,57 +593,27 @@ fn start(args: StartArgs) -> Result { directory_teams: 0, binaries: BTreeMap::new(), failure: None, - sources: sources - .iter() - .map(|(id, registry)| { - ( - id.clone(), - SourceSession { - project: registry.clone(), - binding: None, - }, - ) - }) - .collect(), }; - ports(state.casework_port, state.mint_port, state.database_port)?; - for port in state.listening_ports() { + ports(state.casework_port, state.issuer_port, state.database_port)?; + for port in [state.casework_port, state.issuer_port, state.database_port] { probe(port)?; } - probe(state.database_port)?; initialize(&root, &state, &clients)?; read_state(&root)? }; let casework = executable("casework", args.casework_bin.as_deref())?; - let mint = executable("mint", args.mint_bin.as_deref())?; let docker = executable("docker", args.docker_bin.as_deref())?; - let bregctl = if state.sources.is_empty() { - None - } else { - Some(executable("bregctl", args.bregctl_bin.as_deref())?) - }; // Identify the prerequisites before the session stops a container or - // launches the supervisor: a casework or mint from another release has to + // launches the supervisor: a casework from another release has to // be named here, while the terminal that asked for the start is reading. state.binaries = BTreeMap::from([ ("casework".into(), binary(&root, &casework)?), - ("mint".into(), binary(&root, &mint)?), ("docker".into(), binary(&root, &docker)?), ]); - if let Some(bregctl) = &bregctl { - state - .binaries - .insert("bregctl".into(), binary(&root, bregctl)?); - } matching_versions(&state.binaries)?; - for port in state.listening_ports() { + for port in [state.casework_port, state.issuer_port] { probe(port)?; } - // Bind the sources while the terminal is reading: a registry session that - // is not running, or one whose issuer cannot be read, is named here. - if let Some(bregctl) = &bregctl { - bind_sources(bregctl, &mut state, &clients)?; - } // Verify the container before accepting a retained database port. if let Some(container) = inspect(&docker, &state)? { if container["State"]["Running"] == true { @@ -1131,8 +646,6 @@ fn start(args: StartArgs) -> Result { .arg(&root) .arg("--casework-bin") .arg(casework) - .arg("--mint-bin") - .arg(mint) .arg("--docker-bin") .arg(docker) .stdin(Stdio::null()) @@ -1245,11 +758,12 @@ fn stop(project_path: &Path, remove: bool, docker_bin: Option<&Path>) -> Result< // No PID-based recovery: unrelated reused PIDs must never be signalled. let _supervisor_lock = completed_supervisor_lock(&root, &state.status)?; if service_ports_must_be_free(&state.status) { - for port in state.listening_ports() { + for port in [state.casework_port, state.issuer_port] { probe(port)?; } } let docker = executable("docker", docker_bin)?; + stop_issuer(&docker, &state)?; // Remove mode tolerates a container already taken by hand: reclaim verifies // ownership of whatever is still there and forgets the rest, so skip the // inspection (and the stop it guards) when nothing is listed under this name. @@ -1586,22 +1100,7 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { ensure_active(&terminate)?; database(&args.docker_bin, &mut state, &terminate)?; ensure_active(&terminate)?; - // A source-backed session borrows the registry session's Mint. - if state.sources.is_empty() { - children.mint = Some(service( - &args.mint_bin, - &["serve", "--config"], - &root.join("mint/mint.yaml"), - &[], - &root, - "mint", - )?); - ready( - &format!("{}/ready", state.mint_origin()), - children.mint.as_ref().context("Mint child missing")?, - &terminate, - )?; - } + issuer(&args.docker_bin, &state, &terminate)?; ensure_active(&terminate)?; // Migrations are idempotent and guarded by an advisory lock. Run them // on every start so a retained database is upgraded with the binaries @@ -1638,8 +1137,8 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { )?; // A client token lives 300 seconds, which the child and readiness // deadlines of a slow first start can exhaust before the seed runs. - // Mint the seeding tokens once Casework is ready, not before it. - tokens(&args.mint_bin, &state, &clients, &terminate)?; + // Issue seeding tokens after Casework is ready. + tokens(&state, &clients, &terminate)?; ensure_active(&terminate)?; seed(&mut state, &clients, &terminate)?; ensure_active(&terminate)?; @@ -1686,10 +1185,12 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { Ok(stop_stream) })(); let child_cleanup = children.stop(); + let issuer_cleanup = stop_issuer(&args.docker_bin, &state); let database_cleanup = stop_database(&args.docker_bin, &state); let socket_cleanup = remove_socket(&root); if result.is_err() || child_cleanup.is_err() + || issuer_cleanup.is_err() || database_cleanup.is_err() || socket_cleanup.is_err() { @@ -1699,6 +1200,7 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { state.failure = [ result.as_ref().err(), child_cleanup.as_ref().err(), + issuer_cleanup.as_ref().err(), database_cleanup.as_ref().err(), socket_cleanup.as_ref().err(), ] @@ -1709,6 +1211,7 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { state.save()?; result?; child_cleanup?; + issuer_cleanup?; database_cleanup?; socket_cleanup?; unreachable!("a failed cleanup returned its error"); @@ -1745,11 +1248,10 @@ fn read_control_command(stream: &mut impl Read) -> Result> { #[derive(Default)] struct Children { casework: Option, - mint: Option, } impl Children { fn exited(&self) -> Result { - for service in [&self.casework, &self.mint].into_iter().flatten() { + for service in [&self.casework].into_iter().flatten() { if service.guard_exit()?.is_some() { return Ok(true); } @@ -1758,7 +1260,7 @@ impl Children { } fn stop(&mut self) -> Result<()> { let mut error = None; - for owned in [&mut self.casework, &mut self.mint] { + for owned in [&mut self.casework] { if let Some(mut service) = owned.take() { if let Err(cause) = service.stop() { error = Some(cause); @@ -2178,14 +1680,14 @@ fn reported_version(binary: &Binary) -> Option<&str> { binary.version.split_whitespace().nth(1) } -/// Refuse a session whose casework, mint or bregctl comes from another -/// release. The executables share a configuration contract, a token shape and -/// a schema, so an older casework beside this caseworkctl fails deep inside a +/// Refuse a session whose casework comes from another release. The +/// two executables share a configuration contract, a token shape and a +/// schema, so an older casework beside this caseworkctl fails deep inside a /// supervised phase, where the cause reads as an unrelated refusal about the /// database. Docker belongs to no release of this stack and is never compared. fn matching_versions(binaries: &BTreeMap) -> Result<()> { let own = registry_platform_buildinfo::DISPLAY_VERSION; - for name in ["casework", "mint", "bregctl"] { + for name in ["casework"] { let Some(prerequisite) = binaries.get(name) else { continue; }; @@ -2194,7 +1696,7 @@ fn matching_versions(binaries: &BTreeMap) -> Result<()> { }; if reported != own { bail!( - "the installed {name} at {} reports version {reported}, and this caseworkctl reports version {own}. A local session runs casework, mint, caseworkctl and, for a source-backed project, bregctl together, so install them from the same release, or put the matching build first on PATH", + "the installed {name} at {} reports version {reported}, and this caseworkctl reports version {own}. A local session runs casework and caseworkctl together, so install both from the same release, or put the matching build first on PATH", prerequisite.path.display() ); } @@ -3442,10 +2944,43 @@ fn stop_database(docker: &Path, state: &State) -> Result<()> { Ok(()) } -fn tokens(mint: &Path, state: &State, clients: &Clients, terminate: &AtomicBool) -> Result<()> { - issue_tokens(state, clients, terminate, |id| { - token(mint, state, id, terminate) - }) +fn issuer(docker: &Path, state: &State, terminate: &AtomicBool) -> Result<()> { + let pin = registry_thunderid_tooling::version::ThunderIdPin::load()?; + let state_root = state.root().join("issuer"); + let session = registry_thunderid_tooling::container::Session { + label: &format!("casework-dev-{}", state.owner), + id: "casework-local", + port: state.issuer_port, + state_root: &state_root, + image: &pin.image, + }; + let jwks = registry_thunderid_tooling::local::start(&session, docker, &mut || { + terminate.load(Ordering::Relaxed) + })?; + private::replace( + &state.root().join("secrets/issuer-jwks"), + &serde_json::to_vec(&jwks)?, + ) +} + +fn stop_issuer(docker: &Path, state: &State) -> Result<()> { + let state_root = state.root().join("issuer"); + if !state_root.join("session.json").exists() { + return Ok(()); + } + let pin = registry_thunderid_tooling::version::ThunderIdPin::load()?; + let session = registry_thunderid_tooling::container::Session { + label: &format!("casework-dev-{}", state.owner), + id: "casework-local", + port: state.issuer_port, + state_root: &state_root, + image: &pin.image, + }; + registry_thunderid_tooling::local::stop(&session, docker).map_err(Into::into) +} + +fn tokens(state: &State, clients: &Clients, terminate: &AtomicBool) -> Result<()> { + issue_tokens(state, clients, terminate, |id| token(state, id, terminate)) } fn issue_tokens( @@ -3474,34 +3009,49 @@ fn issue_tokens( issue(&administrator.id) } -fn token(mint: &Path, state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { +fn token(state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { + use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; + ensure_active(terminate)?; let root = state.root(); - let bytes = Zeroizing::new(command_cancellable( - Command::new(mint) - .arg("token") - .arg("--url") - .arg(state.token_endpoint()) - .arg("--client-id") - .arg(id) - .arg("--key") - .arg(root.join("credentials").join(id).join("assertion-key.jwk")), - &root, - "token", - None, - terminate, - )?); - let value = std::str::from_utf8(&bytes) - .context("Mint token output must be ASCII")? - .trim(); - if value.len() > 65536 - || value.split('.').count() != 3 - || value.chars().any(char::is_whitespace) - { - bail!("Mint returned an invalid compact token"); - } + let clients: Clients = + serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; + let client = clients + .clients + .iter() + .find(|client| client.id == id) + .context("the local client is not registered")?; + let bytes = private::read( + &root.join("credentials").join(id).join("assertion-key.jwk"), + 4096, + )?; + let text = Zeroizing::new( + String::from_utf8(bytes.to_vec()).context("the retained client key is unreadable")?, + ); + let key = registry_platform_crypto::PrivateJwk::parse(&text) + .map_err(|_| anyhow::anyhow!("the retained client key is unusable"))?; + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{}/oauth2/token", state.issuer_origin()).parse()?, + id, + key, + ) + .with_audience(state.issuer_origin()) + .with_resource(state.audience()) + .with_scopes(client.scopes.clone()), + )?; + let value = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(provider.bearer_token())?; + ensure_active(terminate)?; + let header = value.authorization_header_value(); + let token = header + .to_str()? + .strip_prefix("Bearer ") + .context("the issued credential is not bearer")?; private::replace( &root.join("secrets").join(format!("{id}-token")), - value.as_bytes(), + token.as_bytes(), ) } @@ -3681,7 +3231,7 @@ fn seed(state: &mut State, clients: &Clients, terminate: &AtomicBool) -> Result< Ok(body) }; let mut directory = read_directory(&token)?; - let issuer = state.issuer(); + let issuer = state.issuer_origin(); let principals: BTreeMap<&str, &str> = state .clients .iter() diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index 725b65675b..9d7cf45fb2 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -7,12 +7,12 @@ use registry_casework::RuntimeConfig; fn session(project: &Path) -> State { State { - version: 1, + version: 2, project: project.to_path_buf(), owner: uuid::Uuid::new_v4().to_string(), status: Status::Stopped, casework_port: 8092, - mint_port: 8093, + issuer_port: 8093, database_port: 55433, clients_file: project.join("dev-clients.yaml"), source_digest: String::new(), @@ -199,7 +199,7 @@ fn clients_file_refuses_duplicate_and_non_rfc6749_scopes() { } #[test] -fn clients_file_refuses_invalid_and_mint_reserved_claim_names() { +fn clients_file_refuses_invalid_and_reserved_claim_names() { let invalid_name = STANDALONE_DEV_CLIENTS.replace( "registry_actor_kind: human", r"'registry\actor_kind': human", @@ -504,7 +504,7 @@ fn generated_operator_config_loads_through_the_runtime_contract() { assert!(matches!( config.authentication.oidc.jwks_source, registry_casework::OidcJwksSource::Static { ref document_ref } - if document_ref == "secret:file/mint-jwks" + if document_ref == "secret:file/issuer-jwks" )); assert_eq!( config.listener.tls_termination, @@ -537,7 +537,7 @@ fn generated_operator_config_loads_through_the_runtime_contract() { Some("secret:file/database-root.pem") ); assert!(config.sources.is_empty()); - assert_eq!(config.authentication.oidc.issuer, state.mint_origin()); + assert_eq!(config.authentication.oidc.issuer, state.issuer_origin()); assert_eq!(config.authentication.oidc.audience, state.audience()); } @@ -1361,17 +1361,17 @@ fn ports_must_be_three_distinct_loopback_ports() { #[test] fn start_ports_fall_back_to_named_environment_variables() { let casework_var = "CASEWORKCTL_DEV_CASEWORK_PORT"; - let mint_var = "CASEWORKCTL_DEV_MINT_PORT"; + let issuer_var = "CASEWORKCTL_DEV_ISSUER_PORT"; let database_var = "CASEWORKCTL_DEV_DATABASE_PORT"; std::env::set_var(casework_var, "19092"); - std::env::set_var(mint_var, "19093"); + std::env::set_var(issuer_var, "19093"); std::env::set_var(database_var, "19099"); let parsed = crate::Cli::try_parse_from(["caseworkctl", "dev", "start", "/tmp/casework-project"]) .unwrap(); std::env::remove_var(casework_var); - std::env::remove_var(mint_var); + std::env::remove_var(issuer_var); std::env::remove_var(database_var); let crate::Command::Dev(dev_args) = parsed.command else { @@ -1385,7 +1385,7 @@ fn start_ports_fall_back_to_named_environment_variables() { panic!("expected dev start"); }; assert_eq!(start.casework_port, Some(19092)); - assert_eq!(start.mint_port, Some(19093)); + assert_eq!(start.issuer_port, Some(19093)); assert_eq!(start.database_port, Some(19099)); } @@ -1531,10 +1531,9 @@ fn a_stopped_session_retains_an_explicit_equivalent_clients_file() { project: project.clone(), clients_file: Some(replacement.clone()), casework_port: None, - mint_port: None, + issuer_port: None, database_port: None, casework_bin: Some(project.join("missing-casework")), - mint_bin: None, docker_bin: None, source_project: Vec::new(), bregctl_bin: None, @@ -1593,10 +1592,9 @@ fn an_active_session_refuses_an_equivalent_clients_file_at_a_new_path() { project: project.clone(), clients_file: Some(replacement), casework_port: None, - mint_port: None, + issuer_port: None, database_port: None, casework_bin: None, - mint_bin: None, docker_bin: None, source_project: Vec::new(), bregctl_bin: None, @@ -1722,7 +1720,10 @@ fn the_report_names_every_local_credential_without_a_secret() { state.directory_teams = 1; let report = state.report(); assert_eq!(report["caseworkUrl"], "http://127.0.0.1:8092"); - assert_eq!(report["tokenEndpoint"], "http://127.0.0.1:8093/token"); + assert_eq!( + report["tokenEndpoint"], + "http://127.0.0.1:8093/oauth2/token" + ); assert_eq!(report["audience"], state.audience()); assert_eq!(report["directory"]["teams"], 1); assert_eq!(report["directory"]["revision"], 1); @@ -3396,7 +3397,6 @@ fn service_cleanup_joins_every_log_pump() { }); let mut children = Children { casework: Some(Service::from_guard(child, vec![pump]).unwrap()), - mint: None, }; let deadline = Instant::now() + Duration::from_secs(2); while !children.exited().unwrap() && Instant::now() < deadline { @@ -3407,3 +3407,27 @@ fn service_cleanup_joins_every_log_pump() { children.stop().unwrap(); assert!(joined.load(Ordering::Relaxed)); } + +#[test] +fn legacy_issuer_state_and_unsafe_token_clients_are_refused_without_effects() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let state = session(&project); + private::directory(&project.join(".casework")).unwrap(); + private::directory(&state.root()).unwrap(); + let mut legacy = serde_json::to_value(&state).unwrap(); + legacy["version"] = json!(1); + let bytes = serde_json::to_vec(&legacy).unwrap(); + private::create(&state.root().join("state.json"), &bytes).unwrap(); + assert!(read_state(&state.root()) + .unwrap_err() + .to_string() + .contains("uses Mint")); + assert_eq!(fs::read(state.root().join("state.json")).unwrap(), bytes); + for id in ["../staff", "/staff", "", "staff/header"] { + assert!(fresh_token(&project, id) + .unwrap_err() + .to_string() + .contains("bounded local client")); + } +} diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 1795ae6528..8e4b968bbc 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -584,6 +584,11 @@ fn semantic_diagnostic_location(error: &ConfigError) -> (&'static str, String, & ); } let (artifact, path, action) = match error { + ConfigError::TaskTemplate => ( + "casework_project", + "casework.yaml:/taskTemplates", + "Use unique task templates with eligible teams and human profiles, valid source fields, exact bounds, and a lifetime of at most 900 seconds.", + ), ConfigError::Envelope => ( "casework_project", "casework.yaml:/apiVersion", diff --git a/crates/registry-stack-client-node/breg/client.d.ts b/crates/registry-stack-client-node/breg/client.d.ts index e4e8fcc9ad..a17841ccf8 100644 --- a/crates/registry-stack-client-node/breg/client.d.ts +++ b/crates/registry-stack-client-node/breg/client.d.ts @@ -53,7 +53,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: PrivateJwk + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string | null + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string | null + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray | null assertionLifetimeSeconds?: SafeInteger | null refreshMarginSeconds?: SafeInteger | null requestTimeoutMilliseconds?: SafeInteger | null diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 039592f111..60e7999040 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -21,6 +21,29 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface TaskApprovalRequest { templateId: string; templateVersion: string } +export interface TaskTemplatePreview { + id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger +} +export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } +/** Grant metadata deliberately excludes stored subject values. */ +export interface TaskGrantView { + id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean +} +export interface TaskGrantList { grants: ReadonlyArray } +export interface TaskGrantRevocation { id: string; invalidated: boolean } +/** A short-lived credential. Do not persist or log the assertion. */ +export interface TaskAssertionResponse { assertion: string; expiresAt: SafeInteger; grantExpiresAt: SafeInteger } +export interface TaskGrantStatusDetails { + grantId: string; authority: string; sourceIssuer: string; principal: string; client: string; resource: string; purpose: string + bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; expiresAt: SafeInteger +} +export interface TaskGrantStatus { active: boolean; grant?: TaskGrantStatusDetails } + export interface DirectoryMember { issuer: string; subject: string; displayName?: string | null } export interface SubjectRef { sourceId: string; kind: string; id: string } export interface SourceBinding { @@ -411,6 +434,12 @@ export class CaseworkClient { listWorkItems(token: string, profile: string, sourceProfile: string, query: ListWorkItemsQuery): Promise> nextWorkItem(token: string, profile: string, sourceProfile: string, query?: NextWorkItemQuery | null): Promise> getWorkItem(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + previewTaskTemplates(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + listTaskGrants(token: string, profile: string, sourceProfile: string, itemId: string): Promise> + approveTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, expectedRevision: SafeInteger, idempotencyKey: string, approval: TaskApprovalRequest): Promise> + revokeTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, grantId: string): Promise> + taskAssertion(token: string, grantId: string): Promise> + taskGrantStatus(token: string, grantId: string): Promise> claimWorkItem(token: string, profile: string, sourceProfile: string, action: CaseworkAction, idempotencyKey: string): Promise> releaseWorkItem(token: string, profile: string, sourceProfile: string, action: CaseworkAction, idempotencyKey: string): Promise> getDraft(token: string, profile: string, sourceProfile: string, itemId: string): Promise> diff --git a/crates/registry-stack-client-node/casework/client.js b/crates/registry-stack-client-node/casework/client.js index 1a713ff7cb..9b5814321e 100644 --- a/crates/registry-stack-client-node/casework/client.js +++ b/crates/registry-stack-client-node/casework/client.js @@ -112,6 +112,12 @@ for (const [method, jsonIndexes] of [ ['listWorkItems', [3]], ['nextWorkItem', [3]], ['getWorkItem', []], + ['previewTaskTemplates', []], + ['listTaskGrants', []], + ['approveTaskGrant', [6]], + ['revokeTaskGrant', []], + ['taskAssertion', []], + ['taskGrantStatus', []], ['claimWorkItem', [3]], ['releaseWorkItem', [3]], ['getDraft', []], diff --git a/crates/registry-stack-client-node/casework/index.d.ts b/crates/registry-stack-client-node/casework/index.d.ts index 3b55a55a3a..8da15309b5 100644 --- a/crates/registry-stack-client-node/casework/index.d.ts +++ b/crates/registry-stack-client-node/casework/index.d.ts @@ -19,6 +19,12 @@ export declare class CaseworkClient { listWorkItems(token: string, profile: string, sourceProfile: string, query: any): Promise nextWorkItem(token: string, profile: string, sourceProfile: string, query?: any | undefined | null): Promise getWorkItem(token: string, profile: string, sourceProfile: string, itemId: string): Promise + previewTaskTemplates(token: string, profile: string, sourceProfile: string, itemId: string): Promise + listTaskGrants(token: string, profile: string, sourceProfile: string, itemId: string): Promise + approveTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, expectedRevision: number, idempotencyKey: string, approval: any): Promise + revokeTaskGrant(token: string, profile: string, sourceProfile: string, itemId: string, grantId: string): Promise + taskAssertion(token: string, grantId: string): Promise + taskGrantStatus(token: string, grantId: string): Promise claimWorkItem(token: string, profile: string, sourceProfile: string, action: any, idempotencyKey: string): Promise releaseWorkItem(token: string, profile: string, sourceProfile: string, action: any, idempotencyKey: string): Promise getDraft(token: string, profile: string, sourceProfile: string, itemId: string): Promise diff --git a/crates/registry-stack-client-node/evidence/client.d.ts b/crates/registry-stack-client-node/evidence/client.d.ts index 82fbe35f51..97ccefcd07 100644 --- a/crates/registry-stack-client-node/evidence/client.d.ts +++ b/crates/registry-stack-client-node/evidence/client.d.ts @@ -82,7 +82,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: Readonly> + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number requestTimeoutMs?: number diff --git a/crates/registry-stack-client-node/relay/client.d.ts b/crates/registry-stack-client-node/relay/client.d.ts index f2f9304e57..49eec30a23 100644 --- a/crates/registry-stack-client-node/relay/client.d.ts +++ b/crates/registry-stack-client-node/relay/client.d.ts @@ -14,7 +14,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: PrivateJwk + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string | null + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string | null + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray | null assertionLifetimeSeconds?: SafeInteger | null refreshMarginSeconds?: SafeInteger | null requestTimeoutMilliseconds?: SafeInteger | null diff --git a/products/casework/README.md b/products/casework/README.md index 9124863a7e..9ba1fade3e 100644 --- a/products/casework/README.md +++ b/products/casework/README.md @@ -5,6 +5,7 @@ for small human decisions requested by another service. It supports two standalone deployment profiles: a hosted decision needs no registry source, while the original checkpoint connects one governed Base Registry Engine change-request source. A project may configure either surface or both. +Source-backed holders can also [approve bounded agent tasks](TASK_GRANTS.md). ## Standalone hosted decisions diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md new file mode 100644 index 0000000000..a4b1003b62 --- /dev/null +++ b/products/casework/TASK_GRANTS.md @@ -0,0 +1,84 @@ +# Approve bounded work for an institutional agent + +A task grant records a current human holder's explicit approval for one +institutional agent to perform a bounded task under its own principal. This +surface applies to source-backed work items. The source remains authoritative +for proposal identity and disclosed subject facts. Hosted decisions retain +their separate workflow. + +## Declare templates + +Add `taskTemplates` to the Casework project. Each template declares: + +- A stable `id`, immutable `version`, and human-readable `label`. +- `eligibleTeams`, human Staff/Supervisor `eligibleProfiles`, `source`, + `itemKinds`, and eligible `itemStates`. +- Exact `agent: {issuer, subject}`, OAuth `client`, one absolute `resource`, + and `purpose`. +- `bounds`, either `{type: evidence, requirement: ...}` or + `{type: breg, permissions: [{collection: ..., operations: [...]}]}`. +- `subjects`, mapping token identity keys to governed source logical fields. +- `lifetimeSeconds`, no more than 900 seconds. + +Use exact product operation names. Wildcards and duplicate bounds are refused. +Required source fields must be disclosed to the approving human and exposed to +the configured service reader for later checks. Callers cannot supply subject +values. Changing a template requires a new version. Retiring a version +invalidates its live grants; reactivation does not restore those grants. + +## Configure the authority + +Runtime `taskAuthority` declares `id`, assertion `issuer`, `exchangeAudience`, +`signingKeyRef`, and `statusClients`. Register its public JWKS, served at +`/.well-known/jwks.json`, with the token-exchange issuer. Keep the private ES256 +or RS256 signing key in the configured secret provider, with its registered key +identifier. + +Include agent and resource status clients in OIDC `allowedClients`. Template +agent issuers must match that verifier. Register the agent's client-credentials +bootstrap for only the Casework resource and `casework:grants:assert`. Register +token exchange separately for its exact resource and access scopes. The agent +uses its own `private_key_jwt` credential for both requests. + +`statusClients` maps each resource server's service client to one exact resource +audience. These clients use `casework:grants:status`. Configure the matching +BREG [`taskGrantStatus`](../breg/TASK_GRANTS.md) entry for governed writes. + +## Preview and approve + +1. The current eligible holder reads + `GET /v1/work-items/{itemId}/task-templates` using Casework and source + profiles. The response includes current item revision and the exact + disclosed authorization to review. +2. After explicit approval, call + `POST /v1/work-items/{itemId}/task-grants` with only `templateId` and + `templateVersion`, current `If-Match`, and `Idempotency-Key`. Casework + rechecks eligibility, proposal identity, and subject facts. +3. The agent obtains its bootstrap token and calls + `POST /v1/task-grants/{grantId}/assertion` with an empty body. This endpoint + rejects human profile headers and verifies the exact agent tuple. +4. Exchange the assertion using RFC 8693. Assertions last at most 60 seconds; + access tokens last at most 300 seconds. Neither extends the grant deadline. + +The unified Node/Python Casework clients expose these operations. App Kit's +source-item view provides review, approval, retry, listing, and revocation. +Credentials stay on the server. Exact approval retries return the original +grant without extending its deadline. New authorization requires explicit new +approval and a new grant ID. Listings omit retained subject selectors. + +## Revocation and status + +An eligible current holder calls +`POST /v1/work-items/{itemId}/task-grants/{grantId}/revoke` with an empty body. +Resource servers call `GET /v1/task-grants/{grantId}/status`; inactive status +returns no grant detail. Machine callers cannot substitute resources or subjects. + +Holder eligibility loss, retired templates, and definitive proposal or subject +changes permanently invalidate grants. Temporary source failure refuses the +current request without permanent invalidation. A mutable source revision alone +does not change frozen proposal identity. + +Offline reads may continue until the earlier access-token or grant deadline. +BREG checks fresh status before new writes, later approval, and application. +There is no distributed transaction between that check and the resource commit. +Task-bound deferred wallet offers are refused. diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index 300c1f7c00..4dc550f846 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -652,6 +652,13 @@ "$ref": "#/components/schemas/SourcePolicy" }, "type": "array" + }, + "taskTemplates": { + "items": { + "$ref": "#/components/schemas/TaskTemplate" + }, + "maxItems": 64, + "type": "array" } }, "required": [ @@ -1473,7 +1480,10 @@ "action_completed", "attempt_settled", "superseded", - "completed" + "completed", + "task_approved", + "task_revoked", + "task_invalidated" ], "type": "string" }, @@ -3949,350 +3959,2245 @@ ], "type": "object" }, - "TeamRecord": { + "TaskApprovalRequest": { "additionalProperties": false, "properties": { - "id": { + "templateId": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "members": { - "items": { - "$ref": "#/components/schemas/DirectoryMember" - }, - "type": "array" + "templateVersion": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "templateId", + "templateVersion" + ], + "type": "object" + }, + "TaskAssertionResponse": { + "additionalProperties": false, + "properties": { + "assertion": { + "description": "Sensitive short-lived credential. Do not log or persist.", + "type": "string" }, - "revision": { - "format": "int64", + "expiresAt": { + "minimum": 0, "type": "integer" }, - "servedQueues": { + "grantExpiresAt": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "assertion", + "expiresAt", + "grantExpiresAt" + ], + "type": "object" + }, + "TaskAuthorityJwks": { + "additionalProperties": false, + "properties": { + "keys": { "items": { - "type": "string" + "type": "object" }, "type": "array" + } + }, + "required": [ + "keys" + ], + "type": "object" + }, + "TaskGrantBounds": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "requirement": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "type": { + "const": "evidence" + } + }, + "required": [ + "type", + "requirement" + ], + "type": "object" }, - "supervisors": { + { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/TaskPermission" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "breg" + } + }, + "required": [ + "type", + "permissions" + ], + "type": "object" + } + ] + }, + "TaskGrantList": { + "additionalProperties": false, + "properties": { + "grants": { "items": { - "$ref": "#/components/schemas/DirectoryMember" + "$ref": "#/components/schemas/TaskGrantView" }, + "maxItems": 128, "type": "array" } }, + "required": [ + "grants" + ], + "type": "object" + }, + "TaskGrantRevocation": { + "additionalProperties": false, + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "invalidated": { + "type": "boolean" + } + }, "required": [ "id", - "members", - "supervisors", - "servedQueues", - "revision" + "invalidated" ], "type": "object" }, - "WorkItem": { + "TaskGrantStatus": { "additionalProperties": false, "properties": { - "actions": { - "items": { - "$ref": "#/components/schemas/CaseworkAction" - }, - "type": "array" + "active": { + "type": "boolean" }, - "assignment": { - "anyOf": [ - { - "$ref": "#/components/schemas/AssignmentContext" - }, - { - "type": "null" - } - ] + "grant": { + "$ref": "#/components/schemas/TaskGrantStatusDetails" + } + }, + "required": [ + "active" + ], + "type": "object" + }, + "TaskGrantStatusDetails": { + "additionalProperties": false, + "properties": { + "authority": { + "maxLength": 512, + "minLength": 1, + "type": "string" }, - "binding": { - "$ref": "#/components/schemas/SourceBinding" + "bounds": { + "$ref": "#/components/schemas/TaskGrantBounds" }, - "bindingReference": { + "client": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "clockOccurrences": { - "items": { - "$ref": "#/components/schemas/ClockOccurrenceView" - }, - "type": "array" + "expiresAt": { + "minimum": 0, + "type": "integer" }, - "displayReference": { - "description": "Human-facing source reference disclosed by the source to the current caller. Present only for source-backed items whose request policy explicitly names a reference field and whose current caller read discloses that field.", + "grantId": { + "format": "uuid", + "type": "string" + }, + "principal": { "maxLength": 512, "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", "type": "string" }, - "firstObservedAt": { - "format": "date-time", + "purpose": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "heldSince": { - "format": "date-time", + "resource": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "holder": { - "anyOf": [ - { - "$ref": "#/components/schemas/IssuerPrincipal" - }, - { - "type": "null" - } - ] + "sourceIssuer": { + "maxLength": 512, + "minLength": 1, + "type": "string" }, - "hosted": { - "anyOf": [ - { - "$ref": "#/components/schemas/HostedWorkItemContext" - }, - { - "type": "null" - } - ] + "subjects": { + "additionalProperties": { + "type": [ + "string", + "integer", + "boolean" + ] + }, + "maxProperties": 32, + "type": "object" + } + }, + "required": [ + "grantId", + "authority", + "sourceIssuer", + "principal", + "client", + "resource", + "purpose", + "bounds", + "subjects", + "expiresAt" + ], + "type": "object" + }, + "TaskGrantView": { + "additionalProperties": false, + "properties": { + "agent": { + "$ref": "#/components/schemas/IssuerPrincipal" }, - "itemId": { - "format": "uuid", + "bounds": { + "$ref": "#/components/schemas/TaskGrantBounds" + }, + "client": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "liveAttempt": { - "$ref": "#/components/schemas/AttemptStatus" + "expiresAt": { + "minimum": 0, + "type": "integer" }, - "occurrenceKind": { - "enum": [ - "review", - "application", - "hosted" - ], + "id": { + "format": "uuid", "type": "string" }, - "passiveDueAt": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ] + "invalidated": { + "type": "boolean" }, - "queueId": { + "purpose": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "revision": { - "format": "int64", - "type": "integer" - }, - "routing": { - "anyOf": [ - { - "$ref": "#/components/schemas/WorkItemRouting" - }, - { - "type": "null" - } - ] + "resource": { + "maxLength": 512, + "minLength": 1, + "type": "string" }, - "routingCopy": { - "anyOf": [ - { - "$ref": "#/components/schemas/CorrectionRoutingCopy" - }, - { - "type": "null" - } - ] + "templateId": { + "maxLength": 512, + "minLength": 1, + "type": "string" }, - "stage": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "state": { - "enum": [ - "open", - "claimed", - "waiting_applicant", - "waiting_application", - "synchronizing", - "completed", - "superseded", - "cancelled" - ], + "templateVersion": { + "maxLength": 512, + "minLength": 1, "type": "string" - }, - "subject": { - "$ref": "#/components/schemas/SubjectRef" - }, - "updatedAt": { - "format": "date-time", + } + }, + "required": [ + "id", + "templateId", + "templateVersion", + "agent", + "client", + "resource", + "purpose", + "bounds", + "expiresAt", + "invalidated" + ], + "type": "object" + }, + "TaskPermission": { + "additionalProperties": false, + "properties": { + "collection": { + "maxLength": 512, + "minLength": 1, "type": "string" + }, + "operations": { + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true } }, "required": [ - "itemId", - "subject", - "occurrenceKind", - "binding", - "bindingReference", - "state", - "queueId", - "revision", - "firstObservedAt", - "updatedAt", - "actions" + "collection", + "operations" ], "type": "object" }, - "WorkItemPage": { + "TaskTemplate": { "additionalProperties": false, "properties": { - "items": { + "agent": { + "$ref": "#/components/schemas/IssuerPrincipal" + }, + "bounds": { + "$ref": "#/components/schemas/TaskGrantBounds" + }, + "client": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "eligibleProfiles": { "items": { - "$ref": "#/components/schemas/WorkItem" + "maxLength": 512, + "minLength": 1, + "type": "string" }, "type": "array" }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "eligibleTeams": { + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "type": "array" }, - "servedQueues": { - "description": "Sorted unique queue identifiers currently served by the authenticated Staff or Supervisor. Present even when items is empty.", + "id": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "itemKinds": { "items": { + "maxLength": 512, + "minLength": 1, "type": "string" }, - "type": "array", - "uniqueItems": true + "type": "array" }, - "status": { - "enum": [ - "complete", - "budget_exhausted", - "source_unavailable" - ], + "itemStates": { + "items": { + "enum": [ + "claimed", + "waiting_applicant", + "waiting_application" + ] + }, + "type": "array" + }, + "label": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "lifetimeSeconds": { + "maximum": 900, + "minimum": 1, + "type": "integer" + }, + "purpose": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "resource": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "source": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "subjects": { + "additionalProperties": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxProperties": 32, + "minProperties": 1, + "type": "object" + }, + "version": { + "maxLength": 512, + "minLength": 1, "type": "string" } }, "required": [ - "items", - "servedQueues", - "status" + "id", + "version", + "label", + "agent", + "client", + "resource", + "purpose", + "bounds", + "subjects", + "lifetimeSeconds", + "eligibleTeams", + "eligibleProfiles", + "source", + "itemKinds", + "itemStates" ], "type": "object" }, - "WorkItemRouting": { + "TaskTemplatePreview": { "additionalProperties": false, "properties": { - "because": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "agent": { + "$ref": "#/components/schemas/IssuerPrincipal" }, - "policyDigest": { - "anyOf": [ - { - "maxLength": 71, - "minLength": 71, - "pattern": "^sha256:[0-9a-f]{64}$", - "type": "string" - }, - { - "type": "null" - } - ] + "bounds": { + "$ref": "#/components/schemas/TaskGrantBounds" }, - "ruleId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "client": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "id": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "label": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "lifetimeSeconds": { + "maximum": 900, + "minimum": 1, + "type": "integer" + }, + "purpose": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "resource": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "subjects": { + "additionalProperties": { + "type": [ + "string", + "integer", + "boolean" + ] + }, + "maxProperties": 32, + "type": "object" + }, + "version": { + "maxLength": 512, + "minLength": 1, + "type": "string" } }, + "required": [ + "id", + "version", + "label", + "agent", + "client", + "resource", + "purpose", + "bounds", + "subjects", + "lifetimeSeconds" + ], "type": "object" }, - "WorkingDaysAfter": { + "TaskTemplatePreviews": { "additionalProperties": false, "properties": { - "workingDays": { - "maximum": 3650, - "minimum": 1, + "itemRevision": { + "minimum": 0, "type": "integer" + }, + "templates": { + "items": { + "$ref": "#/components/schemas/TaskTemplatePreview" + }, + "type": "array" } }, "required": [ - "workingDays" + "itemRevision", + "templates" ], "type": "object" }, - "WorkingDaysBefore": { + "TeamRecord": { "additionalProperties": false, "properties": { - "workingDaysBefore": { - "maximum": 3650, - "minimum": 1, + "id": { + "type": "string" + }, + "members": { + "items": { + "$ref": "#/components/schemas/DirectoryMember" + }, + "type": "array" + }, + "revision": { + "format": "int64", "type": "integer" + }, + "servedQueues": { + "items": { + "type": "string" + }, + "type": "array" + }, + "supervisors": { + "items": { + "$ref": "#/components/schemas/DirectoryMember" + }, + "type": "array" } }, "required": [ - "workingDaysBefore" + "id", + "members", + "supervisors", + "servedQueues", + "revision" ], "type": "object" - } - }, - "securitySchemes": { - "bearerAuth": { - "bearerFormat": "JWT", - "description": "A fresh trusted-issuer token. Staff, Supervisor, and Administrator profiles require the configured human identity assertion. Requester is a service integration profile exempt from that assertion; selecting it never adds human-role or decision authority.", - "scheme": "bearer", - "type": "http" }, - "webhookSignature": { - "description": "HMAC-SHA256 signature over the exact bounded event request.", - "in": "header", - "name": "X-Registry-Signature", - "type": "apiKey" + "WorkItem": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/CaseworkAction" + }, + "type": "array" + }, + "assignment": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssignmentContext" + }, + { + "type": "null" + } + ] + }, + "binding": { + "$ref": "#/components/schemas/SourceBinding" + }, + "bindingReference": { + "type": "string" + }, + "clockOccurrences": { + "items": { + "$ref": "#/components/schemas/ClockOccurrenceView" + }, + "type": "array" + }, + "displayReference": { + "description": "Human-facing source reference disclosed by the source to the current caller. Present only for source-backed items whose request policy explicitly names a reference field and whose current caller read discloses that field.", + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string" + }, + "firstObservedAt": { + "format": "date-time", + "type": "string" + }, + "heldSince": { + "format": "date-time", + "type": "string" + }, + "holder": { + "anyOf": [ + { + "$ref": "#/components/schemas/IssuerPrincipal" + }, + { + "type": "null" + } + ] + }, + "hosted": { + "anyOf": [ + { + "$ref": "#/components/schemas/HostedWorkItemContext" + }, + { + "type": "null" + } + ] + }, + "itemId": { + "format": "uuid", + "type": "string" + }, + "liveAttempt": { + "$ref": "#/components/schemas/AttemptStatus" + }, + "occurrenceKind": { + "enum": [ + "review", + "application", + "hosted" + ], + "type": "string" + }, + "passiveDueAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "queueId": { + "type": "string" + }, + "revision": { + "format": "int64", + "type": "integer" + }, + "routing": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkItemRouting" + }, + { + "type": "null" + } + ] + }, + "routingCopy": { + "anyOf": [ + { + "$ref": "#/components/schemas/CorrectionRoutingCopy" + }, + { + "type": "null" + } + ] + }, + "stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state": { + "enum": [ + "open", + "claimed", + "waiting_applicant", + "waiting_application", + "synchronizing", + "completed", + "superseded", + "cancelled" + ], + "type": "string" + }, + "subject": { + "$ref": "#/components/schemas/SubjectRef" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "itemId", + "subject", + "occurrenceKind", + "binding", + "bindingReference", + "state", + "queueId", + "revision", + "firstObservedAt", + "updatedAt", + "actions" + ], + "type": "object" + }, + "WorkItemPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/WorkItem" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "servedQueues": { + "description": "Sorted unique queue identifiers currently served by the authenticated Staff or Supervisor. Present even when items is empty.", + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "status": { + "enum": [ + "complete", + "budget_exhausted", + "source_unavailable" + ], + "type": "string" + } + }, + "required": [ + "items", + "servedQueues", + "status" + ], + "type": "object" + }, + "WorkItemRouting": { + "additionalProperties": false, + "properties": { + "because": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "policyDigest": { + "anyOf": [ + { + "maxLength": 71, + "minLength": 71, + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ruleId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "WorkingDaysAfter": { + "additionalProperties": false, + "properties": { + "workingDays": { + "maximum": 3650, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "workingDays" + ], + "type": "object" + }, + "WorkingDaysBefore": { + "additionalProperties": false, + "properties": { + "workingDaysBefore": { + "maximum": 3650, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "workingDaysBefore" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "A fresh trusted-issuer token. Staff, Supervisor, and Administrator profiles require the configured human identity assertion. Requester is a service integration profile exempt from that assertion; selecting it never adds human-role or decision authority.", + "scheme": "bearer", + "type": "http" + }, + "webhookSignature": { + "description": "HMAC-SHA256 signature over the exact bounded event request.", + "in": "header", + "name": "X-Registry-Signature", + "type": "apiKey" + } + } + }, + "info": { + "description": "Implemented Casework HTTP contract for BReg-backed work and source-free hosted decisions. Casework and source profiles are independent authority selections.", + "license": { + "identifier": "Apache-2.0", + "name": "Apache-2.0" + }, + "title": "Registry Casework API", + "version": "v1alpha1" + }, + "openapi": "3.1.0", + "paths": { + "/.well-known/jwks.json": { + "get": { + "operationId": "getTaskAuthorityKeys", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskAuthorityJwks" + } + } + }, + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Task authority public verification keys" + } + }, + "/events/sources/{source_id}": { + "post": { + "description": "Authentication uses the configured BReg webhook signature and timestamp headers. The raw body is bounded to 1 MiB and grants no authority or display content.", + "operationId": "acceptSourceEvent", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Configured source identifier.", + "in": "path", + "name": "source_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "CloudEvents version; exactly 1.0.", + "in": "header", + "name": "ce-specversion", + "required": true, + "schema": { + "const": "1.0", + "type": "string" + } + }, + { + "description": "Event UUID.", + "in": "header", + "name": "ce-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Configured exact event source URI.", + "in": "header", + "name": "ce-source", + "required": true, + "schema": { + "format": "uri", + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Configured exact request-lifecycle event type.", + "in": "header", + "name": "ce-type", + "required": true, + "schema": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Event timestamp.", + "in": "header", + "name": "ce-time", + "required": true, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Configured event data schema URI.", + "in": "header", + "name": "ce-dataschema", + "required": true, + "schema": { + "format": "uri", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Activated positive BReg source generation.", + "in": "header", + "name": "x-registry-event-generation", + "required": true, + "schema": { + "maxLength": 19, + "pattern": "^[1-9][0-9]{0,18}$", + "type": "string" + } + }, + { + "description": "Positive delivery attempt number.", + "in": "header", + "name": "x-registry-delivery-attempt", + "required": true, + "schema": { + "maxLength": 19, + "pattern": "^[1-9][0-9]{0,18}$", + "type": "string" + } + }, + { + "description": "Signed delivery timestamp.", + "in": "header", + "name": "x-registry-delivery-time", + "required": true, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Signed delivery key derived from the exact event, delivery, generation, payload, and destination binding.", + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 71, + "minLength": 71, + "pattern": "^sha256:[0-9a-f]{64}$", + "type": "string" + } + }, + { + "description": "BReg Version 1 HMAC-SHA-256 signature over the exact request.", + "in": "header", + "name": "x-registry-signature", + "required": true, + "schema": { + "maxLength": 46, + "minLength": 46, + "pattern": "^v1=[A-Za-z0-9_-]{43}$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/cloudevents+json": { + "schema": { + "type": "object" + } + }, + "application/json": { + "schema": { + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Signature accepted; authoritative readback is scheduled.", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceSignatureInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, source.signature-invalid", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceNotFound" + } + } + }, + "description": "Problem response: source.not-found", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] + } + } + }, + "description": "Problem response: service.unavailable, work-item.source-unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "webhookSignature": [] + } + ], + "summary": "Accept a signed source synchronization hint", + "x-maximum-body-bytes": 1048576, + "x-maximum-signed-metadata-bytes": 32768 + } + }, + "/health": { + "get": { + "operationId": "health", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Process is live.", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Liveness" + } + }, + "/ready": { + "get": { + "operationId": "readiness", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Ready.", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Database readiness" + } + }, + "/v1/casework": { + "get": { + "description": "Returns the configured queues, BReg sources, authored calendars and clocks, and hosted kinds visible to an authenticated profile. Requester profiles receive empty calendar and clock lists. Description data grants no item or source authority.", + "operationId": "describeCasework", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Description" + } + } + }, + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Describe the Casework project" + } + }, + "/v1/directory": { + "get": { + "description": "Administrator-only. Returns one consistent snapshot of the directory revision, teams, memberships, and served queues. The serialized JSON response is bounded to 2 MiB.", + "operationId": "getDirectory", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DirectoryResponse" + } + } + }, + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read the current authorized directory" + } + }, + "/v1/directory/absences": { + "get": { + "description": "Staff see their own absences, Supervisors see absences for staff they currently supervise, and Administrators see all absence records. The response carries the current global directoryRevision required in If-Match for a following absence write. Its items contain at most 1000 caller-authorized records ordered by start time and absenceId. The serialized page is bounded to 2 MiB and may contain fewer records than limit to fit that bound. Follow nextCursor until it is absent to enumerate every record.", + "operationId": "listAbsences", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to the caller, selected profile, role, page size, and directory revision. On cursor.expired or a changed directory revision (cursor.invalid), restart without the cursor. Authority is checked again on every page.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Page size from 1 through 1000; defaults to 1000. Values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 1000, + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbsenceList" + } + } + }, + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + }, + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] + } + } + }, + "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] + } + } + }, + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List authorized absence records" + }, + "post": { + "description": "Uses the directory revision in If-Match. Staff can manage their own absence with cover from the same team; Supervisors can manage currently supervised staff; Administrators can manage any directory staff. The period is start-inclusive and end-exclusive.", + "operationId": "createAbsence", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + { + "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "maxLength": 21, + "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbsenceInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbsenceRecord" + } + } + }, + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] + } + } + }, + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + } + }, + "description": "Problem response: idempotency.key-reused", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemAbsenceCoverCycle" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceInvalidPeriod" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceOverlap" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceSelfCover" + }, + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + ] + } + } + }, + "description": "Problem response: absence.cover-cycle, absence.invalid-period, absence.overlap, absence.self-cover, request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Record an absence" } - } - }, - "info": { - "description": "Implemented Casework HTTP contract for BReg-backed work and source-free hosted decisions. Casework and source profiles are independent authority selections.", - "license": { - "identifier": "Apache-2.0", - "name": "Apache-2.0" }, - "title": "Registry Casework API", - "version": "v1alpha1" - }, - "openapi": "3.1.0", - "paths": { - "/events/sources/{source_id}": { - "post": { - "description": "Authentication uses the configured BReg webhook signature and timestamp headers. The raw body is bounded to 1 MiB and grants no authority or display content.", - "operationId": "acceptSourceEvent", + "/v1/directory/absences/{absence_id}": { + "delete": { + "description": "Deletes one authorized absence under the current positive directory revision.", + "operationId": "deleteAbsence", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -4304,154 +6209,335 @@ } }, { - "description": "Configured source identifier.", - "in": "path", - "name": "source_id", + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", "required": true, "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "CloudEvents version; exactly 1.0.", + "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", - "name": "ce-specversion", + "name": "If-Match", "required": true, "schema": { - "const": "1.0", + "maxLength": 21, + "pattern": "^\"[1-9][0-9]{0,18}\"$", "type": "string" } }, { - "description": "Event UUID.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", - "name": "ce-id", + "name": "Idempotency-Key", "required": true, "schema": { - "format": "uuid", + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", "type": "string" } }, { - "description": "Configured exact event source URI.", - "in": "header", - "name": "ce-source", + "description": "Absence UUID.", + "in": "path", + "name": "absence_id", "required": true, "schema": { - "format": "uri", - "maxLength": 512, - "minLength": 1, + "format": "uuid", "type": "string" } + } + ], + "responses": { + "204": { + "description": "Success", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] + } + } + }, + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + } + }, + "description": "Problem response: idempotency.key-reused", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } }, - { - "description": "Configured exact request-lifecycle event type.", - "in": "header", - "name": "ce-type", - "required": true, - "schema": { - "maxLength": 512, - "minLength": 1, - "type": "string" + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } } }, - { - "description": "Event timestamp.", - "in": "header", - "name": "ce-time", - "required": true, - "schema": { - "format": "date-time", - "type": "string" + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } } - }, + } + }, + "security": [ { - "description": "Configured event data schema URI.", - "in": "header", - "name": "ce-dataschema", - "required": true, - "schema": { - "format": "uri", - "maxLength": 2048, - "minLength": 1, - "type": "string" - } - }, + "bearerAuth": [] + } + ], + "summary": "Delete an absence" + }, + "put": { + "description": "Replaces one absence under the current positive directory revision and the same authority and validation rules as creation.", + "operationId": "updateAbsence", + "parameters": [ { - "description": "Activated positive BReg source generation.", + "description": "Optional W3C trace context continued in the response.", "in": "header", - "name": "x-registry-event-generation", - "required": true, + "name": "traceparent", + "required": false, "schema": { - "maxLength": 19, - "pattern": "^[1-9][0-9]{0,18}$", "type": "string" } }, { - "description": "Positive delivery attempt number.", + "description": "Explicit Casework access profile. It never selects BReg authority.", "in": "header", - "name": "x-registry-delivery-attempt", + "name": "Registry-Casework-Profile", "required": true, "schema": { - "maxLength": 19, - "pattern": "^[1-9][0-9]{0,18}$", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Signed delivery timestamp.", + "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", - "name": "x-registry-delivery-time", + "name": "If-Match", "required": true, "schema": { - "format": "date-time", + "maxLength": 21, + "pattern": "^\"[1-9][0-9]{0,18}\"$", "type": "string" } }, { - "description": "Signed delivery key derived from the exact event, delivery, generation, payload, and destination binding.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", - "name": "idempotency-key", + "name": "Idempotency-Key", "required": true, "schema": { - "maxLength": 71, - "minLength": 71, - "pattern": "^sha256:[0-9a-f]{64}$", + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", "type": "string" } }, { - "description": "BReg Version 1 HMAC-SHA-256 signature over the exact request.", - "in": "header", - "name": "x-registry-signature", + "description": "Absence UUID.", + "in": "path", + "name": "absence_id", "required": true, "schema": { - "maxLength": 46, - "minLength": 46, - "pattern": "^v1=[A-Za-z0-9_-]{43}$", + "format": "uuid", "type": "string" } } ], "requestBody": { "content": { - "application/cloudevents+json": { - "schema": { - "type": "object" - } - }, "application/json": { "schema": { - "type": "object" + "$ref": "#/components/schemas/AbsenceInput" } } }, "required": true }, "responses": { - "202": { - "description": "Signature accepted; authoritative readback is scheduled.", + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AbsenceRecord" + } + } + }, + "description": "Success", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -4462,244 +6548,208 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceSignatureInvalid" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source.signature-invalid", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "404": { + "401": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceNotFound" + "$ref": "#/components/schemas/ProblemAuthenticationRefused" } } }, - "description": "Problem response: source.not-found", + "description": "Problem response: authentication.refused", "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "403": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: idempotency.key-reused", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "503": { + "412": { "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemPreconditionFailed" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: precondition.failed", "headers": { - "Retry-After": { - "description": "Seconds before retrying the unavailable dependency.", - "schema": { - "minimum": 0, - "type": "integer" - } - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } - } - }, - "security": [ - { - "webhookSignature": [] - } - ], - "summary": "Accept a signed source synchronization hint", - "x-maximum-body-bytes": 1048576, - "x-maximum-signed-metadata-bytes": 32768 - } - }, - "/health": { - "get": { - "operationId": "health", - "parameters": [ - { - "description": "Optional W3C trace context continued in the response.", - "in": "header", - "name": "traceparent", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Process is live.", + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "415": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.unsupported-media-type", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "422": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemAbsenceCoverCycle" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceInvalidPeriod" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceOverlap" + }, + { + "$ref": "#/components/schemas/ProblemAbsenceSelfCover" + }, + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + ] } } }, - "description": "Problem response: request.body-too-large", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - } - }, - "security": [], - "summary": "Liveness" - } - }, - "/ready": { - "get": { - "operationId": "readiness", - "parameters": [ - { - "description": "Optional W3C trace context continued in the response.", - "in": "header", - "name": "traceparent", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Ready.", + "description": "Problem response: absence.cover-cycle, absence.invalid-period, absence.overlap, absence.self-cover, request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "428": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemPreconditionRequired" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: precondition.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -4729,14 +6779,18 @@ } } }, - "security": [], - "summary": "Database readiness" + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Replace an absence" } }, - "/v1/casework": { - "get": { - "description": "Returns the configured queues, BReg sources, authored calendars and clocks, and hosted kinds visible to an authenticated profile. Requester profiles receive empty calendar and clock lists. Description data grants no item or source authority.", - "operationId": "describeCasework", + "/v1/directory/bootstrap": { + "post": { + "description": "Administrator-only. The resulting directory JSON must fit the 2 MiB aggregate limit; a larger directory returns request.invalid before any change commits.", + "operationId": "bootstrapDirectory", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -4758,14 +6812,47 @@ "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } + }, + { + "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "maxLength": 21, + "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BootstrapDirectoryRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Description" + "$ref": "#/components/schemas/DirectoryResponse" } } }, @@ -4822,12 +6909,15 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -4849,6 +6939,36 @@ } } }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + } + }, + "description": "Problem response: idempotency.key-reused", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -4863,6 +6983,88 @@ "$ref": "#/components/headers/TraceparentHeader" } } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "500": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRuntimeFailure" + } + } + }, + "description": "Problem response: runtime.failure", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } } }, "security": [ @@ -4870,42 +7072,76 @@ "bearerAuth": [] } ], - "summary": "Describe the Casework project" + "summary": "Bootstrap the directory as an Administrator" } }, - "/v1/directory": { - "get": { - "description": "Administrator-only. Returns one consistent snapshot of the directory revision, teams, memberships, and served queues. The serialized JSON response is bounded to 2 MiB.", - "operationId": "getDirectory", + "/v1/directory/caseload/apply": { + "post": { + "description": "Supervisor-only for currently served queues. Applies only the 1 through 100 distinct item selections and expected revisions supplied after preview. There is no global If-Match. Each item is processed atomically and returns moved, not_visible, not_eligible, attempt_in_progress, or conflict; revision is present only for moved. Without Registry-Source-Profile, source-backed selections return not_visible.", + "operationId": "applyCaseloadMove", "parameters": [ { - "description": "Optional W3C trace context continued in the response.", + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + { + "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", - "name": "traceparent", + "name": "Registry-Source-Profile", "required": false, "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Explicit Casework access profile. It never selects BReg authority.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", - "name": "Registry-Casework-Profile", + "name": "Idempotency-Key", "required": true, "schema": { "maxLength": 128, "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", + "pattern": "^[!-~]+$", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaseloadApplyRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectoryResponse" + "$ref": "#/components/schemas/CaseloadItemResultList" } } }, @@ -4958,19 +7194,19 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -4992,6 +7228,21 @@ } } }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + } + }, + "description": "Problem response: idempotency.key-reused", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -5007,6 +7258,36 @@ } } }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "500": { "content": { "application/problem+json": { @@ -5022,15 +7303,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -5050,13 +7353,13 @@ "bearerAuth": [] } ], - "summary": "Read the current authorized directory" + "summary": "Apply a reviewed caseload move" } }, - "/v1/directory/absences": { - "get": { - "description": "Staff see their own absences, Supervisors see absences for staff they currently supervise, and Administrators see all absence records. The response carries the current global directoryRevision required in If-Match for a following absence write. Its items contain at most 1000 caller-authorized records ordered by start time and absenceId. The serialized page is bounded to 2 MiB and may contain fewer records than limit to fit that bound. Follow nextCursor until it is absent to enumerate every record.", - "operationId": "listAbsences", + "/v1/directory/caseload/preview": { + "post": { + "description": "Supervisor-only preview for currently served queues. The from and to principals must differ. Candidates are held by movement.from and optionally restricted to queueId. Concealed, denied, or missing candidates are omitted without disclosing their count. Without Registry-Source-Profile only hosted candidates are visible; source-backed candidates require it.", + "operationId": "previewCaseloadMove", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -5080,7 +7383,19 @@ } }, { - "description": "Opaque 15-minute cursor bound to the caller, selected profile, role, page size, and directory revision. On cursor.expired or a changed directory revision (cursor.invalid), restart without the cursor. Authority is checked again on every page.", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", + "required": false, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to the human principal, selected Casework profile, optional source profile, and exact movement. Malformed or context-mismatched values are cursor.invalid; expired values are cursor.expired.", "in": "query", "name": "cursor", "required": false, @@ -5089,24 +7404,33 @@ } }, { - "description": "Page size from 1 through 1000; defaults to 1000. Values outside that range are request.invalid.", + "description": "Page size from 1 through 100; values outside that range are request.invalid.", "in": "query", "name": "limit", "required": false, "schema": { - "default": 1000, - "maximum": 1000, + "maximum": 100, "minimum": 1, "type": "integer" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CaseloadMoveRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AbsenceList" + "$ref": "#/components/schemas/CaseloadPreviewPage" } } }, @@ -5127,15 +7451,12 @@ }, { "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" } ] } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "description": "Problem response: cursor.invalid, request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -5233,6 +7554,36 @@ } } }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "500": { "content": { "application/problem+json": { @@ -5248,15 +7599,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -5276,11 +7649,13 @@ "bearerAuth": [] } ], - "summary": "List authorized absence records" - }, + "summary": "Preview a caller-visible caseload move" + } + }, + "/v1/directory/clocks/recompute/apply": { "post": { - "description": "Uses the directory revision in If-Match. Staff can manage their own absence with cover from the same team; Supervisors can manage currently supervised staff; Administrators can manage any directory staff. The period is start-inclusive and end-exclusive.", - "operationId": "createAbsence", + "description": "Administrator-only. Applies the actor-bound reviewed preview atomically. An expired preview returns clock.recompute-preview-expired; an already-applied preview or changed calculation generation returns precondition.failed. Create and review a new preview after either response.", + "operationId": "applyClockRecompute", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -5303,17 +7678,6 @@ "type": "string" } }, - { - "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "maxLength": 21, - "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", - "type": "string" - } - }, { "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", @@ -5331,18 +7695,18 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AbsenceInput" + "$ref": "#/components/schemas/ClockRecomputeApplyRequest" } } }, "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AbsenceRecord" + "$ref": "#/components/schemas/ClockRecomputeResult" } } }, @@ -5407,7 +7771,22 @@ } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -5444,91 +7823,75 @@ } } }, - "412": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemClockRecomputePreviewExpired" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: clock.recompute-preview-expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemPreconditionFailed" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "415": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: request.unsupported-media-type", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "422": { + "415": { "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemAbsenceCoverCycle" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceInvalidPeriod" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceOverlap" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceSelfCover" - }, - { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - ] + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" } } }, - "description": "Problem response: absence.cover-cycle, absence.invalid-period, absence.overlap, absence.self-cover, request.unprocessable", + "description": "Problem response: request.unsupported-media-type", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "428": { + "422": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRequestUnprocessable" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -5578,13 +7941,13 @@ "bearerAuth": [] } ], - "summary": "Record an absence" + "summary": "Apply a reviewed clock recalculation" } }, - "/v1/directory/absences/{absence_id}": { - "delete": { - "description": "Deletes one authorized absence under the current positive directory revision.", - "operationId": "deleteAbsence", + "/v1/directory/clocks/recompute/preview": { + "post": { + "description": "Administrator-only. Recalculates at most 100 active occurrences against the named immutable holiday revision. The result is bound to the actor and selected profile for 15 minutes and records each expected calculation generation for review before apply.", + "operationId": "previewClockRecompute", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -5606,43 +7969,27 @@ "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } - }, - { - "description": "Quoted positive signed 64-bit Casework item revision.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", - "type": "string" - } - }, - { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", - "in": "header", - "name": "Idempotency-Key", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[!-~]+$", - "type": "string" - } - }, - { - "description": "Absence UUID.", - "in": "path", - "name": "absence_id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockRecomputeRequest" + } + } + }, + "required": true + }, "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClockRecomputePreview" + } + } + }, "description": "Success", "headers": { "traceparent": { @@ -5741,60 +8088,45 @@ } } }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" - } - } - }, - "description": "Problem response: idempotency.key-reused", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "412": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "415": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: request.unsupported-media-type", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "428": { + "422": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRequestUnprocessable" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -5844,11 +8176,13 @@ "bearerAuth": [] } ], - "summary": "Delete an absence" - }, - "put": { - "description": "Replaces one absence under the current positive directory revision and the same authority and validation rules as creation.", - "operationId": "updateAbsence", + "summary": "Preview clock recalculation" + } + }, + "/v1/directory/holidays": { + "post": { + "description": "Administrator-only. Stores one immutable positive revision with at most 3660 distinct ISO dates. Repeating the exact revision is idempotent; different content for an existing holiday-set revision fails its precondition.", + "operationId": "createHolidayRevision", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -5871,17 +8205,6 @@ "type": "string" } }, - { - "description": "Quoted positive signed 64-bit Casework item revision.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", - "type": "string" - } - }, { "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", @@ -5893,34 +8216,24 @@ "pattern": "^[!-~]+$", "type": "string" } - }, - { - "description": "Absence UUID.", - "in": "path", - "name": "absence_id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AbsenceInput" + "$ref": "#/components/schemas/HolidaySetRevisionInput" } } }, "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AbsenceRecord" + "$ref": "#/components/schemas/HolidaySetDocument" } } }, @@ -5992,21 +8305,6 @@ } } }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" - } - } - }, - "description": "Problem response: work-item.not-visible", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "405": { "content": { "application/problem+json": { @@ -6086,42 +8384,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemAbsenceCoverCycle" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceInvalidPeriod" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceOverlap" - }, - { - "$ref": "#/components/schemas/ProblemAbsenceSelfCover" - }, - { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - ] - } - } - }, - "description": "Problem response: absence.cover-cycle, absence.invalid-period, absence.overlap, absence.self-cover, request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRequestUnprocessable" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6171,13 +8438,13 @@ "bearerAuth": [] } ], - "summary": "Replace an absence" + "summary": "Create an immutable holiday-set revision" } }, - "/v1/directory/bootstrap": { - "post": { - "description": "Administrator-only. The resulting directory JSON must fit the 2 MiB aggregate limit; a larger directory returns request.invalid before any change commits.", - "operationId": "bootstrapDirectory", + "/v1/directory/holidays/{id}/revisions/{revision}": { + "get": { + "description": "Administrator-only read of one immutable holiday-set revision.", + "operationId": "getHolidayRevision", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -6201,45 +8468,33 @@ } }, { - "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", - "in": "header", - "name": "If-Match", + "description": "Configured holiday-set identifier.", + "in": "path", + "name": "id", "required": true, "schema": { - "maxLength": 21, - "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", + "maxLength": 64, + "pattern": "^[a-z][a-z0-9-]{0,63}$", "type": "string" } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", - "in": "header", - "name": "Idempotency-Key", + "description": "Positive immutable holiday-set revision.", + "in": "path", + "name": "revision", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[!-~]+$", - "type": "string" + "minimum": 1, + "type": "integer" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BootstrapDirectoryRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectoryResponse" + "$ref": "#/components/schemas/HolidaySetDocument" } } }, @@ -6292,64 +8547,49 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "405": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" - } - } - }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "409": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } } }, - "description": "Problem response: idempotency.key-reused", + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "412": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6371,51 +8611,6 @@ } } }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - } - }, - "description": "Problem response: request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" - } - } - }, - "description": "Problem response: precondition.required", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "500": { "content": { "application/problem+json": { @@ -6459,13 +8654,13 @@ "bearerAuth": [] } ], - "summary": "Bootstrap the directory as an Administrator" + "summary": "Read one holiday-set revision" } }, - "/v1/directory/caseload/apply": { - "post": { - "description": "Supervisor-only for currently served queues. Applies only the 1 through 100 distinct item selections and expected revisions supplied after preview. There is no global If-Match. Each item is processed atomically and returns moved, not_visible, not_eligible, attempt_in_progress, or conflict; revision is present only for moved. Without Registry-Source-Profile, source-backed selections return not_visible.", - "operationId": "applyCaseloadMove", + "/v1/directory/targets": { + "get": { + "description": "Returns only directory members currently eligible for the requested use. Each member carries an issuer-qualified identity and may carry the display name stored on an authorized team membership. assignment is available to Staff currently serving the queue and Supervisors currently supervising it, and lists current Staff serving that queue across teams; Administrators use the existing full directory for assignment discovery. absence_person lists people the caller may currently manage; absence_cover rechecks authority over the exact person and lists valid current covers under the existing absence roles. Requester profiles are refused. Empty eligible sets return a complete page. No source profile is accepted, and teams and absence details are never returned.", + "operationId": "listDirectoryTargets", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -6489,46 +8684,82 @@ } }, { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", - "in": "header", - "name": "Registry-Source-Profile", + "description": "Required target-discovery purpose. assignment requires queue and forbids person fields; absence_person forbids queue and person fields; absence_cover requires both exact personIssuer and personSubject and forbids queue.", + "in": "query", + "name": "purpose", + "required": true, + "schema": { + "enum": [ + "assignment", + "absence_person", + "absence_cover" + ], + "type": "string" + } + }, + { + "description": "Required nonempty queue identifier for assignment; forbidden for absence purposes.", + "in": "query", + "name": "queue", "required": false, "schema": { - "maxLength": 128, "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", - "in": "header", - "name": "Idempotency-Key", - "required": true, + "description": "Exact issuer of the managed absent person. Required together with personSubject for absence_cover and forbidden otherwise.", + "in": "query", + "name": "personIssuer", + "required": false, "schema": { - "maxLength": 128, + "maxLength": 2048, "minLength": 1, - "pattern": "^[!-~]+$", + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string", + "x-maximum-utf8-bytes": 2048 + } + }, + { + "description": "Exact subject of the managed absent person. Required together with personIssuer for absence_cover and forbidden otherwise.", + "in": "query", + "name": "personSubject", + "required": false, + "schema": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string", + "x-maximum-utf8-bytes": 2048 + } + }, + { + "description": "Opaque 15-minute cursor bound to the authenticated human principal, selected Casework profile, purpose, queue, and person fields. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by issuer and subject.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { "type": "string" } + }, + { + "description": "Page size from 1 through 100; values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CaseloadApplyRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CaseloadItemResultList" + "$ref": "#/components/schemas/DirectoryTargetPage" } } }, @@ -6543,11 +8774,21 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + }, + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6615,15 +8856,15 @@ } } }, - "409": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: idempotency.key-reused", + "description": "Problem response: cursor.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6638,67 +8879,22 @@ } } }, - "description": "Problem response: request.body-too-large", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - } - }, - "description": "Problem response: request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "500": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" - } - } - }, - "description": "Problem response: runtime.failure", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6709,18 +8905,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemServiceUnavailable" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: service.unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -6740,13 +8929,13 @@ "bearerAuth": [] } ], - "summary": "Apply a reviewed caseload move" + "summary": "List current directory targets" } }, - "/v1/directory/caseload/preview": { - "post": { - "description": "Supervisor-only preview for currently served queues. The from and to principals must differ. Candidates are held by movement.from and optionally restricted to queueId. Concealed, denied, or missing candidates are omitted without disclosing their count. Without Registry-Source-Profile only hosted candidates are visible; source-backed candidates require it.", - "operationId": "previewCaseloadMove", + "/v1/directory/teams/{team_id}": { + "put": { + "description": "Administrator-only. Replaces the named team's staff, supervisors, and served queues under the loaded directory revision. A queue already served by another team returns precondition.failed; remove it from that team before assigning it here. Authority changes take effect immediately. Casework releases newly ineligible held items through bounded maintenance, while unresolved source attempts remain held for a later retry. The response contains directory state and no work-item identifiers. The resulting directory JSON must fit the 2 MiB aggregate limit; a larger directory returns request.invalid before any change commits.", + "operationId": "updateDirectoryTeam", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -6770,35 +8959,39 @@ } }, { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", "in": "header", - "name": "Registry-Source-Profile", - "required": false, + "name": "If-Match", + "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", + "maxLength": 21, + "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", "type": "string" } }, { - "description": "Opaque 15-minute cursor bound to the human principal, selected Casework profile, optional source profile, and exact movement. Malformed or context-mismatched values are cursor.invalid; expired values are cursor.expired.", - "in": "query", - "name": "cursor", - "required": false, + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "in": "header", + "name": "Idempotency-Key", + "required": true, "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", "type": "string" } }, { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, + "description": "Directory team identifier.", + "in": "path", + "name": "team_id", + "required": true, "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9._-]+$", + "type": "string", + "x-maximum-utf8-bytes": 128 } } ], @@ -6806,7 +8999,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CaseloadMoveRequest" + "$ref": "#/components/schemas/DirectoryTeamUpdateRequest" } } }, @@ -6817,7 +9010,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CaseloadPreviewPage" + "$ref": "#/components/schemas/DirectoryResponse" } } }, @@ -6832,18 +9025,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: cursor.invalid, request.invalid", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6876,20 +9062,20 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6911,15 +9097,30 @@ } } }, - "410": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: idempotency.key-reused", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -6971,30 +9172,30 @@ } } }, - "500": { + "428": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemPreconditionRequired" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: precondition.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7005,18 +9206,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemServiceUnavailable" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: service.unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -7036,13 +9230,13 @@ "bearerAuth": [] } ], - "summary": "Preview a caller-visible caseload move" + "summary": "Create or replace a directory team" } }, - "/v1/directory/clocks/recompute/apply": { - "post": { - "description": "Administrator-only. Applies the actor-bound reviewed preview atomically. An expired preview returns clock.recompute-preview-expired; an already-applied preview or changed calculation generation returns precondition.failed. Create and review a new preview after either response.", - "operationId": "applyClockRecompute", + "/v1/holdings": { + "get": { + "description": "Supervisor-only caller-visible holdings. Counts are grouped by principal and queue within this page; the same group may occur on later pages, so consumers must follow every page and sum matching groups. A source_unavailable page contains zero counts and supplies a cursor that retries the failed page.", + "operationId": "getHoldings", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -7066,174 +9260,131 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", - "name": "Idempotency-Key", + "name": "Registry-Source-Profile", "required": true, "schema": { "maxLength": 128, "minLength": 1, - "pattern": "^[!-~]+$", + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClockRecomputeApplyRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClockRecomputeResult" - } - } - }, - "description": "Success", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" - } - } - }, - "description": "Problem response: request.invalid", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemAuthenticationRefused" - } - } - }, - "description": "Problem response: authentication.refused", - "headers": { - "WWW-Authenticate": { - "description": "Bearer authentication challenge.", - "schema": { - "const": "Bearer" - } - }, - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } + { + "description": "Opaque cursor for the next caller-visible holdings page. Follow every page and sum matching principal and queue groups to obtain totals across the caller-visible caseload. When status is source_unavailable, the page contains zero counts and nextCursor retries the failed page.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "type": "string" } }, - "403": { - "content": { - "application/problem+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, - { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" - }, - { - "$ref": "#/components/schemas/ProblemProfileNotHuman" - } - ] - } - } - }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } + { + "description": "Page size from 1 through 100; values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" } - }, - "404": { + } + ], + "responses": { + "200": { "content": { - "application/problem+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "$ref": "#/components/schemas/HoldingsPage" } } }, - "description": "Problem response: work-item.not-visible", + "description": "Success", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "400": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileRequired" + } + ] } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "409": { + "401": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemAuthenticationRefused" } } }, - "description": "Problem response: idempotency.key-reused", + "description": "Problem response: authentication.refused", "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "410": { + "403": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemClockRecomputePreviewExpired" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] } } }, - "description": "Problem response: clock.recompute-preview-expired", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "412": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7255,45 +9406,30 @@ } } }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: request.unprocessable", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "502": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemSourceBadGateway" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: source.bad-gateway", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7304,11 +9440,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -7328,13 +9471,13 @@ "bearerAuth": [] } ], - "summary": "Apply a reviewed clock recalculation" + "summary": "Read current caller-visible bounded holdings" } }, - "/v1/directory/clocks/recompute/preview": { - "post": { - "description": "Administrator-only. Recalculates at most 100 active occurrences against the named immutable holiday revision. The result is bound to the actor and selected profile for 15 minutes and records each expected calculation generation for review before apply.", - "operationId": "previewClockRecompute", + "/v1/hosted-accountability/{event_id}": { + "get": { + "description": "Supervisor-only protected accountability read. Current team leadership is checked before returning the raw issuer-qualified actor, selected deciding profile, outcome, and staff reason. The read is audited and returns no cancellation record.", + "operationId": "getHostedAccountability", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -7356,24 +9499,24 @@ "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } + }, + { + "description": "Hosted terminal event UUID.", + "in": "path", + "name": "event_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ClockRecomputeRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClockRecomputePreview" + "$ref": "#/components/schemas/HostedAccountabilityRecord" } } }, @@ -7388,11 +9531,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7425,20 +9575,20 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7490,36 +9640,6 @@ } } }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - } - }, - "description": "Problem response: request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "500": { "content": { "application/problem+json": { @@ -7563,13 +9683,13 @@ "bearerAuth": [] } ], - "summary": "Preview clock recalculation" + "summary": "Resolve one retained hosted decision actor" } }, - "/v1/directory/holidays": { + "/v1/hosted-items": { "post": { - "description": "Administrator-only. Stores one immutable positive revision with at most 3660 distinct ISO dates. Repeating the exact revision is idempotent; different content for an existing holiday-set revision fails its precondition.", - "operationId": "createHolidayRevision", + "description": "Requester-only. The selected Requester profile and authenticated issuer-qualified service principal own the item and bound kind grant.", + "operationId": "createHostedItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -7593,7 +9713,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -7609,7 +9729,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HolidaySetRevisionInput" + "$ref": "#/components/schemas/HostedCreateRequest" } } }, @@ -7620,7 +9740,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HolidaySetDocument" + "$ref": "#/components/schemas/RequesterHostedItem" } } }, @@ -7635,12 +9755,45 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: request.invalid, source-profile.not-applicable", "headers": { + "Registry-Casework-Validation-Path": { + "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", + "schema": { + "maxLength": 256, + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, + "Registry-Casework-Validation-Reason": { + "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", + "schema": { + "enum": [ + "kind_not_allowed", + "reference_invalid", + "object_required", + "maximum_bytes_exceeded", + "maximum_depth_exceeded", + "schema_mismatch", + "outcome_not_declared", + "reason_required", + "text_invalid" + ], + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -7672,20 +9825,20 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7722,15 +9875,15 @@ } } }, - "412": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemIdempotencyExpired" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: idempotency.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7825,13 +9978,13 @@ "bearerAuth": [] } ], - "summary": "Create an immutable holiday-set revision" + "summary": "Create a requester-owned hosted item" } }, - "/v1/directory/holidays/{id}/revisions/{revision}": { + "/v1/hosted-items/terminal": { "get": { - "description": "Administrator-only read of one immutable holiday-set revision.", - "operationId": "getHolidayRevision", + "description": "Requester-only. Results are ordered by terminalAt and eventId and remain available only for the hosted kind's terminal retention period.", + "operationId": "listHostedTerminalResults", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -7855,22 +10008,21 @@ } }, { - "description": "Configured holiday-set identifier.", - "in": "path", - "name": "id", - "required": true, - "schema": { - "maxLength": 64, - "pattern": "^[a-z][a-z0-9-]{0,63}$", + "description": "Opaque 15-minute cursor bound to the authenticated Requester issuer, subject, profile, and terminal feed. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by eventId.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { "type": "string" } }, { - "description": "Positive immutable holiday-set revision.", - "in": "path", - "name": "revision", - "required": true, + "description": "Page size from 1 through 100; values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, "schema": { + "maximum": 100, "minimum": 1, "type": "integer" } @@ -7881,7 +10033,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HolidaySetDocument" + "$ref": "#/components/schemas/HostedTerminalPage" } } }, @@ -7896,11 +10048,21 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + }, + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -7933,50 +10095,50 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "404": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: cursor.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8041,13 +10203,12 @@ "bearerAuth": [] } ], - "summary": "Read one holiday-set revision" + "summary": "List this Requester's retained terminal results" } }, - "/v1/directory/targets": { + "/v1/hosted-items/{item_id}": { "get": { - "description": "Returns only directory members currently eligible for the requested use. Each member carries an issuer-qualified identity and may carry the display name stored on an authorized team membership. assignment is available to Staff currently serving the queue and Supervisors currently supervising it, and lists current Staff serving that queue across teams; Administrators use the existing full directory for assignment discovery. absence_person lists people the caller may currently manage; absence_cover rechecks authority over the exact person and lists valid current covers under the existing absence roles. Requester profiles are refused. Empty eligible sets return a complete page. No source profile is accepted, and teams and absence details are never returned.", - "operationId": "listDirectoryTargets", + "operationId": "getHostedItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -8071,74 +10232,14 @@ } }, { - "description": "Required target-discovery purpose. assignment requires queue and forbids person fields; absence_person forbids queue and person fields; absence_cover requires both exact personIssuer and personSubject and forbids queue.", - "in": "query", - "name": "purpose", + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", "required": true, "schema": { - "enum": [ - "assignment", - "absence_person", - "absence_cover" - ], - "type": "string" - } - }, - { - "description": "Required nonempty queue identifier for assignment; forbidden for absence purposes.", - "in": "query", - "name": "queue", - "required": false, - "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "description": "Exact issuer of the managed absent person. Required together with personSubject for absence_cover and forbidden otherwise.", - "in": "query", - "name": "personIssuer", - "required": false, - "schema": { - "maxLength": 2048, - "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", - "type": "string", - "x-maximum-utf8-bytes": 2048 - } - }, - { - "description": "Exact subject of the managed absent person. Required together with personIssuer for absence_cover and forbidden otherwise.", - "in": "query", - "name": "personSubject", - "required": false, - "schema": { - "maxLength": 2048, - "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", - "type": "string", - "x-maximum-utf8-bytes": 2048 - } - }, - { - "description": "Opaque 15-minute cursor bound to the authenticated human principal, selected Casework profile, purpose, queue, and person fields. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by issuer and subject.", - "in": "query", - "name": "cursor", - "required": false, - "schema": { + "format": "uuid", "type": "string" } - }, - { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" - } } ], "responses": { @@ -8146,7 +10247,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectoryTargetPage" + "$ref": "#/components/schemas/RequesterHostedItem" } } }, @@ -8162,9 +10263,6 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, { "$ref": "#/components/schemas/ProblemRequestInvalid" }, @@ -8175,7 +10273,7 @@ } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8208,50 +10306,50 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "410": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8316,13 +10414,12 @@ "bearerAuth": [] } ], - "summary": "List current directory targets" + "summary": "Read this Requester's hosted item" } }, - "/v1/directory/teams/{team_id}": { - "put": { - "description": "Administrator-only. Replaces the named team's staff, supervisors, and served queues under the loaded directory revision. A queue already served by another team returns precondition.failed; remove it from that team before assigning it here. Authority changes take effect immediately. Casework releases newly ineligible held items through bounded maintenance, while unresolved source attempts remain held for a later retry. The response contains directory state and no work-item identifiers. The resulting directory JSON must fit the 2 MiB aggregate limit; a larger directory returns request.invalid before any change commits.", - "operationId": "updateDirectoryTeam", + "/v1/hosted-items/{item_id}/cancel": { + "post": { + "operationId": "cancelHostedItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -8346,18 +10443,18 @@ } }, { - "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", + "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", "name": "If-Match", "required": true, "schema": { "maxLength": 21, - "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", + "pattern": "^\"[1-9][0-9]{0,18}\"$", "type": "string" } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -8369,16 +10466,13 @@ } }, { - "description": "Directory team identifier.", + "description": "Casework item UUID.", "in": "path", - "name": "team_id", + "name": "item_id", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9._-]+$", - "type": "string", - "x-maximum-utf8-bytes": 128 + "format": "uuid", + "type": "string" } } ], @@ -8386,7 +10480,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectoryTeamUpdateRequest" + "$ref": "#/components/schemas/HostedCancelRequest" } } }, @@ -8397,7 +10491,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DirectoryResponse" + "$ref": "#/components/schemas/HostedTerminalResult" } } }, @@ -8412,12 +10506,45 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: request.invalid, source-profile.not-applicable", "headers": { + "Registry-Casework-Validation-Path": { + "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", + "schema": { + "maxLength": 256, + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, + "Registry-Casework-Validation-Reason": { + "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", + "schema": { + "enum": [ + "kind_not_allowed", + "reference_invalid", + "object_required", + "maximum_bytes_exceeded", + "maximum_depth_exceeded", + "schema_mismatch", + "outcome_not_declared", + "reason_required", + "text_invalid" + ], + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -8469,6 +10596,21 @@ } } }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "405": { "content": { "application/problem+json": { @@ -8477,22 +10619,50 @@ } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "409": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemIdempotencyExpired" } } }, - "description": "Problem response: idempotency.key-reused", + "description": "Problem response: idempotency.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8617,13 +10787,13 @@ "bearerAuth": [] } ], - "summary": "Create or replace a directory team" + "summary": "Cancel this Requester's active hosted item" } }, - "/v1/holdings": { + "/v1/hosted-items/{item_id}/notes": { "get": { - "description": "Supervisor-only caller-visible holdings. Counts are grouped by principal and queue within this page; the same group may occur on later pages, so consumers must follow every page and sum matching groups. A source_unavailable page contains zero counts and supplies a cursor that retries the failed page.", - "operationId": "getHoldings", + "description": "Requester-only. Notes are ordered by recordedAt and noteId and remain visible only while the hosted item payload is retained.", + "operationId": "listHostedNotes", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -8647,19 +10817,17 @@ } }, { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", - "in": "header", - "name": "Registry-Source-Profile", + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", + "format": "uuid", "type": "string" } }, { - "description": "Opaque cursor for the next caller-visible holdings page. Follow every page and sum matching principal and queue groups to obtain totals across the caller-visible caseload. When status is source_unavailable, the page contains zero counts and nextCursor retries the failed page.", + "description": "Opaque 15-minute cursor bound to the Requester principal, profile, item, and notes feed.", "in": "query", "name": "cursor", "required": false, @@ -8684,7 +10852,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HoldingsPage" + "$ref": "#/components/schemas/HostedNotePage" } } }, @@ -8700,17 +10868,20 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + }, { "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" } ] } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8763,6 +10934,21 @@ } } }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "405": { "content": { "application/problem+json": { @@ -8778,45 +10964,45 @@ } } }, - "413": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: cursor.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -8827,18 +11013,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemServiceUnavailable" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: service.unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -8858,13 +11037,10 @@ "bearerAuth": [] } ], - "summary": "Read current caller-visible bounded holdings" - } - }, - "/v1/hosted-accountability/{event_id}": { - "get": { - "description": "Supervisor-only protected accountability read. Current team leadership is checked before returning the raw issuer-qualified actor, selected deciding profile, outcome, and staff reason. The read is audited and returns no cancellation record.", - "operationId": "getHostedAccountability", + "summary": "List this Requester's retained hosted notes" + }, + "post": { + "operationId": "addHostedNote", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -8888,9 +11064,32 @@ } }, { - "description": "Hosted terminal event UUID.", + "description": "Quoted positive signed 64-bit Casework item revision.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "maxLength": 21, + "pattern": "^\"[1-9][0-9]{0,18}\"$", + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "Casework item UUID.", "in": "path", - "name": "event_id", + "name": "item_id", "required": true, "schema": { "format": "uuid", @@ -8898,12 +11097,22 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostedNoteRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedAccountabilityRecord" + "$ref": "#/components/schemas/RequesterHostedItem" } } }, @@ -8914,113 +11123,242 @@ } } }, - "400": { + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + } + ] + } + } + }, + "description": "Problem response: request.invalid, source-profile.not-applicable", + "headers": { + "Registry-Casework-Validation-Path": { + "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", + "schema": { + "maxLength": 256, + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, + "Registry-Casework-Validation-Reason": { + "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", + "schema": { + "enum": [ + "kind_not_allowed", + "reference_invalid", + "object_required", + "maximum_bytes_exceeded", + "maximum_depth_exceeded", + "schema_mismatch", + "outcome_not_declared", + "reason_required", + "text_invalid" + ], + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + } + }, + "description": "Problem response: work-item.not-visible", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { "content": { "application/problem+json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" } ] } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "401": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemAuthenticationRefused" + "$ref": "#/components/schemas/ProblemIdempotencyExpired" } } }, - "description": "Problem response: authentication.refused", + "description": "Problem response: idempotency.expired", "headers": { - "WWW-Authenticate": { - "description": "Bearer authentication challenge.", + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { "schema": { - "const": "Bearer" + "$ref": "#/components/schemas/ProblemPreconditionFailed" } - }, + } + }, + "description": "Problem response: precondition.failed", + "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "403": { + "413": { "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" - }, - { - "$ref": "#/components/schemas/ProblemProfileNotHuman" - }, - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - } - ] + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "404": { + "415": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: request.unsupported-media-type", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "422": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemRequestUnprocessable" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "428": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemPreconditionRequired" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: precondition.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9070,13 +11408,13 @@ "bearerAuth": [] } ], - "summary": "Resolve one retained hosted decision actor" + "summary": "Add a note to this Requester's hosted item" } }, - "/v1/hosted-items": { + "/v1/task-grants/{grant_id}/assertion": { "post": { - "description": "Requester-only. The selected Requester profile and authenticated issuer-qualified service principal own the item and bound kind grant.", - "operationId": "createHostedItem", + "description": "Requires a bootstrap agent token with casework:grants:assert, exact single Casework audience, and the registered agent principal/client. Profile headers and grant-bearing tokens are refused. Fresh eligibility and source checks precede issuance; assertion lifetime is at most 60 seconds and never exceeds the original grant deadline.", + "operationId": "getTaskAssertion", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -9100,34 +11438,22 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", - "in": "header", - "name": "Idempotency-Key", + "description": "Immutable task grant identifier.", + "in": "path", + "name": "grant_id", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[!-~]+$", + "format": "uuid", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HostedCreateRequest" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequesterHostedItem" + "$ref": "#/components/schemas/TaskAssertionResponse" } } }, @@ -9142,45 +11468,12 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid", "headers": { - "Registry-Casework-Validation-Path": { - "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", - "schema": { - "maxLength": 256, - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, - "Registry-Casework-Validation-Reason": { - "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", - "schema": { - "enum": [ - "kind_not_allowed", - "reference_invalid", - "object_required", - "maximum_bytes_exceeded", - "maximum_depth_exceeded", - "schema_mismatch", - "outcome_not_declared", - "reason_required", - "text_invalid" - ], - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -9213,124 +11506,149 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "405": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "409": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: idempotency.key-reused", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "410": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] } } }, - "description": "Problem response: idempotency.expired", + "description": "Problem response: work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "412": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemPreconditionFailed" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "415": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: request.unsupported-media-type", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "422": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: request.unprocessable", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "502": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemSourceBadGateway" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: source.bad-gateway", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9341,11 +11659,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -9365,13 +11690,13 @@ "bearerAuth": [] } ], - "summary": "Create a requester-owned hosted item" + "summary": "Issue a short-lived task assertion" } }, - "/v1/hosted-items/terminal": { + "/v1/task-grants/{grant_id}/status": { "get": { - "description": "Requester-only. Results are ordered by terminalAt and eventId and remain available only for the hosted kind's terminal retention period.", - "operationId": "listHostedTerminalResults", + "description": "Requires a service token with casework:grants:status and the registered client for the grant resource. Returns fresh status after Directory and source checks. Consumers must compare every bound and enforce expiresAt. An unavailable check grants no authority.", + "operationId": "getTaskGrantStatus", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -9395,24 +11720,14 @@ } }, { - "description": "Opaque 15-minute cursor bound to the authenticated Requester issuer, subject, profile, and terminal feed. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by eventId.", - "in": "query", - "name": "cursor", - "required": false, + "description": "Immutable task grant identifier.", + "in": "path", + "name": "grant_id", + "required": true, "schema": { + "format": "uuid", "type": "string" } - }, - { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" - } } ], "responses": { @@ -9420,7 +11735,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedTerminalPage" + "$ref": "#/components/schemas/TaskGrantStatus" } } }, @@ -9431,25 +11746,15 @@ } } }, - "400": { - "content": { - "application/problem+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" - } - ] + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9482,20 +11787,42 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] + } + } + }, + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9517,15 +11844,48 @@ } } }, - "410": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", + "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9562,15 +11922,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -9590,12 +11972,13 @@ "bearerAuth": [] } ], - "summary": "List this Requester's retained terminal results" + "summary": "Check current resource-bound task authority" } }, - "/v1/hosted-items/{item_id}": { + "/v1/work-items": { "get": { - "operationId": "getHostedItem", + "description": "With Registry-Source-Profile, reads BReg-backed work under that separate authority. A complete sourceId, subjectKind, and subjectId selector filters by one exact source-neutral subject. An exact reference lookup first uses the explicitly retained source field as a bounded candidate index, then returns an item only when the current caller read still discloses the same reference. Callers must follow every page and handle every visible occurrence rather than assuming a subject or reference is unique. The cursor is bound to the full selector, including a hash of the reference, and the selected sort; the raw reference is not stored in the cursor context. Without Registry-Source-Profile, a human Staff profile configured to decide at least one hosted kind reads hosted work for currently served queues in ascending createdAt and itemId order. A human Supervisor profile reads hosted work whenever the deployment configures any hosted kind. If the deployment configures a source, omission returns source-profile.required when the selected Staff profile decides no hosted kind or when no hosted kind is configured for a Supervisor. Hosted requests reject subject and reference selectors and non-default sorts.", + "operationId": "listWorkItems", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -9619,14 +12002,118 @@ } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", + "required": false, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, + { + "description": "Required view evaluated before pagination.", + "in": "query", + "name": "view", "required": true, "schema": { - "format": "uuid", + "enum": [ + "mine", + "my_teams", + "team_holdings", + "overdue", + "completed_by_me" + ], + "type": "string" + } + }, + { + "description": "Source-backed ordering. due orders by effective due date with undated items last, then age and item id; age orders oldest first; type orders by source-neutral subject kind, then age and item id. Defaults to due. Hosted inboxes reject age and type.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "default": "due", + "enum": [ + "due", + "age", + "type" + ], + "type": "string" + } + }, + { + "description": "Optional queue identifier.", + "in": "query", + "name": "queue", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Exact source identifier. For source-backed requests, supply this together with subjectKind and subjectId or omit all three. The component must be nonempty and is carried without normalization.", + "in": "query", + "name": "sourceId", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Exact source-neutral subject kind. Supply together with sourceId and subjectId or omit all three. The component must be nonempty and is carried without normalization.", + "in": "query", + "name": "subjectKind", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Exact source-neutral subject identifier. Supply together with sourceId and subjectKind or omit all three. The component must be nonempty, is carried without normalization, and is not restricted to UUID syntax.", + "in": "query", + "name": "subjectId", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Exact case-sensitive human reference. Available only for source-backed requests whose source policy explicitly names a displayReference field. Mutually exclusive with the three-part subject selector.", + "in": "query", + "name": "reference", + "required": false, + "schema": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "type": "string" + } + }, + { + "description": "Opaque cursor bound to this authorized query.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { "type": "string" } + }, + { + "description": "Bounded page size; values above 100 are served as 100.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -9634,7 +12121,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequesterHostedItem" + "$ref": "#/components/schemas/WorkItemPage" } } }, @@ -9654,13 +12141,13 @@ "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + "$ref": "#/components/schemas/ProblemSourceProfileRequired" } ] } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9698,15 +12185,12 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" - }, - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -9773,15 +12257,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -9801,12 +12307,13 @@ "bearerAuth": [] } ], - "summary": "Read this Requester's hosted item" + "summary": "List a caller-authorized inbox view" } - }, - "/v1/hosted-items/{item_id}/cancel": { - "post": { - "operationId": "cancelHostedItem", + }, + "/v1/work-items/next": { + "get": { + "description": "Returns a WorkItemPage containing at most one caller-visible item. Empty complete and budget_exhausted pages remain successful responses; follow nextCursor when present. servedQueues is preserved on every page.", + "operationId": "getNextWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -9830,55 +12337,54 @@ } }, { - "description": "Quoted positive signed 64-bit Casework item revision.", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", - "name": "If-Match", + "name": "Registry-Source-Profile", "required": true, "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", - "in": "header", - "name": "Idempotency-Key", - "required": true, + "description": "Optional queue identifier.", + "in": "query", + "name": "queue", + "required": false, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[!-~]+$", "type": "string" } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", - "required": true, + "description": "Opaque cursor bound to the authenticated actor, selected Casework and source profiles, optional queue, the next-item feed, and due ordering.", + "in": "query", + "name": "cursor", + "required": false, "schema": { - "format": "uuid", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HostedCancelRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedTerminalResult" + "allOf": [ + { + "$ref": "#/components/schemas/WorkItemPage" + }, + { + "properties": { + "items": { + "maxItems": 1 + } + }, + "type": "object" + } + ] } } }, @@ -9898,40 +12404,14 @@ "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + "$ref": "#/components/schemas/ProblemSourceProfileRequired" } ] } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid, source-profile.required", "headers": { - "Registry-Casework-Validation-Path": { - "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", - "schema": { - "maxLength": 256, - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, - "Registry-Casework-Validation-Reason": { - "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", - "schema": { - "enum": [ - "kind_not_allowed", - "reference_invalid", - "object_required", - "maximum_bytes_exceeded", - "maximum_depth_exceeded", - "schema_mismatch", - "outcome_not_declared", - "reason_required", - "text_invalid" - ], - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -9968,15 +12448,12 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" - }, - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10013,64 +12490,6 @@ } } }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotHolder" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotOffered" - } - ] - } - } - }, - "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "410": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" - } - } - }, - "description": "Problem response: idempotency.expired", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" - } - } - }, - "description": "Problem response: precondition.failed", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "413": { "content": { "application/problem+json": { @@ -10086,60 +12505,30 @@ } } }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - } - }, - "description": "Problem response: request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "428": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "502": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemSourceBadGateway" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: source.bad-gateway", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10150,11 +12539,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -10174,13 +12570,12 @@ "bearerAuth": [] } ], - "summary": "Cancel this Requester's active hosted item" + "summary": "Get the next caller-visible item" } }, - "/v1/hosted-items/{item_id}/notes": { + "/v1/work-items/{item_id}": { "get": { - "description": "Requester-only. Notes are ordered by recordedAt and noteId and remain visible only while the hosted item payload is retained.", - "operationId": "listHostedNotes", + "operationId": "getWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -10204,33 +12599,25 @@ } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", - "required": true, - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "description": "Opaque 15-minute cursor bound to the Requester principal, profile, item, and notes feed.", - "in": "query", - "name": "cursor", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", "required": false, "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", + "required": true, "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "format": "uuid", + "type": "string" } } ], @@ -10239,7 +12626,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedNotePage" + "$ref": "#/components/schemas/WorkItem" } } }, @@ -10255,20 +12642,17 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, { "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" + "$ref": "#/components/schemas/ProblemSourceProfileRequired" } ] } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10305,16 +12689,13 @@ "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" - }, - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10351,45 +12732,45 @@ } } }, - "410": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "502": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemSourceBadGateway" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: source.bad-gateway", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10400,11 +12781,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -10424,10 +12812,13 @@ "bearerAuth": [] } ], - "summary": "List this Requester's retained hosted notes" - }, + "summary": "Read one currently visible item" + } + }, + "/v1/work-items/{item_id}/assign": { "post": { - "operationId": "addHostedNote", + "description": "Supervisor-only assignment for a currently served queue. Without Registry-Source-Profile the target is a hosted item; a source-backed item requires the source profile. The assignee is resolved through any active absence cover chain. If no eligible cover is available, the item remains open in its queue with staffingDiagnostic no_cover_available.", + "operationId": "assignWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -10450,6 +12841,18 @@ "type": "string" } }, + { + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", + "required": false, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, { "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", @@ -10462,7 +12865,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -10488,7 +12891,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedNoteRequest" + "$ref": "#/components/schemas/AssignmentRequest" } } }, @@ -10499,7 +12902,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RequesterHostedItem" + "$ref": "#/components/schemas/MutationResponse" } } }, @@ -10514,45 +12917,12 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid", "headers": { - "Registry-Casework-Validation-Path": { - "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", - "schema": { - "maxLength": 256, - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, - "Registry-Casework-Validation-Reason": { - "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", - "schema": { - "enum": [ - "kind_not_allowed", - "reference_invalid", - "object_required", - "maximum_bytes_exceeded", - "maximum_depth_exceeded", - "schema_mismatch", - "outcome_not_declared", - "reason_required", - "text_invalid" - ], - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -10585,19 +12955,19 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -10643,35 +13013,22 @@ "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, { - "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotHolder" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", + "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "410": { - "content": { - "application/problem+json": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" - } - } - }, - "description": "Problem response: idempotency.expired", - "headers": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -10767,15 +13124,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -10795,156 +13174,85 @@ "bearerAuth": [] } ], - "summary": "Add a note to this Requester's hosted item" + "summary": "Assign an item" } }, - "/v1/work-items": { - "get": { - "description": "With Registry-Source-Profile, reads BReg-backed work under that separate authority. A complete sourceId, subjectKind, and subjectId selector filters by one exact source-neutral subject. An exact reference lookup first uses the explicitly retained source field as a bounded candidate index, then returns an item only when the current caller read still discloses the same reference. Callers must follow every page and handle every visible occurrence rather than assuming a subject or reference is unique. The cursor is bound to the full selector, including a hash of the reference, and the selected sort; the raw reference is not stored in the cursor context. Without Registry-Source-Profile, a human Staff profile configured to decide at least one hosted kind reads hosted work for currently served queues in ascending createdAt and itemId order. A human Supervisor profile reads hosted work whenever the deployment configures any hosted kind. If the deployment configures a source, omission returns source-profile.required when the selected Staff profile decides no hosted kind or when no hosted kind is configured for a Supervisor. Hosted requests reject subject and reference selectors and non-default sorts.", - "operationId": "listWorkItems", + "/v1/work-items/{item_id}/attempts/recover": { + "post": { + "operationId": "recoverAttemptByKey", "parameters": [ { - "description": "Optional W3C trace context continued in the response.", - "in": "header", - "name": "traceparent", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Explicit Casework access profile. It never selects BReg authority.", - "in": "header", - "name": "Registry-Casework-Profile", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, - { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", - "in": "header", - "name": "Registry-Source-Profile", - "required": false, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, - { - "description": "Required view evaluated before pagination.", - "in": "query", - "name": "view", - "required": true, - "schema": { - "enum": [ - "mine", - "my_teams", - "team_holdings", - "overdue", - "completed_by_me" - ], - "type": "string" - } - }, - { - "description": "Source-backed ordering. due orders by effective due date with undated items last, then age and item id; age orders oldest first; type orders by source-neutral subject kind, then age and item id. Defaults to due. Hosted inboxes reject age and type.", - "in": "query", - "name": "sort", - "required": false, - "schema": { - "default": "due", - "enum": [ - "due", - "age", - "type" - ], - "type": "string" - } - }, - { - "description": "Optional queue identifier.", - "in": "query", - "name": "queue", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Exact source identifier. For source-backed requests, supply this together with subjectKind and subjectId or omit all three. The component must be nonempty and is carried without normalization.", - "in": "query", - "name": "sourceId", - "required": false, - "schema": { - "minLength": 1, - "type": "string" - } - }, - { - "description": "Exact source-neutral subject kind. Supply together with sourceId and subjectId or omit all three. The component must be nonempty and is carried without normalization.", - "in": "query", - "name": "subjectKind", + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", "required": false, "schema": { - "minLength": 1, "type": "string" } }, { - "description": "Exact source-neutral subject identifier. Supply together with sourceId and subjectKind or omit all three. The component must be nonempty, is carried without normalization, and is not restricted to UUID syntax.", - "in": "query", - "name": "subjectId", - "required": false, + "description": "Explicit Casework access profile. It never selects BReg authority.", + "in": "header", + "name": "Registry-Casework-Profile", + "required": true, "schema": { + "maxLength": 128, "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Exact case-sensitive human reference. Available only for source-backed requests whose source policy explicitly names a displayReference field. Mutually exclusive with the three-part subject selector.", - "in": "query", - "name": "reference", - "required": false, + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", + "required": true, "schema": { - "maxLength": 512, + "maxLength": 128, "minLength": 1, - "pattern": "^[^\\u0000-\\u001F\\u007F-\\u009F]+$", + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Opaque cursor bound to this authorized query.", - "in": "query", - "name": "cursor", - "required": false, + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", + "required": true, "schema": { + "format": "uuid", "type": "string" } }, { - "description": "Bounded page size; values above 100 are served as 100.", - "in": "query", - "name": "limit", - "required": false, + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "in": "header", + "name": "Idempotency-Key", + "required": true, "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverAttemptRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkItemPage" + "$ref": "#/components/schemas/MutationResponse" } } }, @@ -11050,6 +13358,36 @@ } } }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", + "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -11065,6 +13403,36 @@ } } }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "500": { "content": { "application/problem+json": { @@ -11112,6 +13480,14 @@ }, "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { + "Registry-Casework-Attempt": { + "description": "Durable attempt UUID when this source mutation was stored but its post-write authoritative read was unavailable.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-present-for-problem-code": "work-item.source-unavailable" + }, "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", "schema": { @@ -11130,13 +13506,12 @@ "bearerAuth": [] } ], - "summary": "List a caller-authorized inbox view" + "summary": "Recover the original attempt selected by idempotency key" } }, - "/v1/work-items/next": { - "get": { - "description": "Returns a WorkItemPage containing at most one caller-visible item. Empty complete and budget_exhausted pages remain successful responses; follow nextCursor when present. servedQueues is preserved on every page.", - "operationId": "getNextWorkItem", + "/v1/work-items/{item_id}/attempts/{attempt_id}/recover": { + "post": { + "operationId": "recoverAttempt", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -11172,42 +13547,42 @@ } }, { - "description": "Optional queue identifier.", - "in": "query", - "name": "queue", - "required": false, + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", + "required": true, "schema": { + "format": "uuid", "type": "string" } }, { - "description": "Opaque cursor bound to the authenticated actor, selected Casework and source profiles, optional queue, the next-item feed, and due ordering.", - "in": "query", - "name": "cursor", - "required": false, + "description": "Original durable attempt UUID.", + "in": "path", + "name": "attempt_id", + "required": true, "schema": { + "format": "uuid", "type": "string" } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecoverAttemptRequest" + } + } + }, + "required": true + }, "responses": { "200": { "content": { "application/json": { "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/WorkItemPage" - }, - { - "properties": { - "items": { - "maxItems": 1 - } - }, - "type": "object" - } - ] + "$ref": "#/components/schemas/MutationResponse" } } }, @@ -11313,6 +13688,36 @@ } } }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", + "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -11328,6 +13733,36 @@ } } }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "500": { "content": { "application/problem+json": { @@ -11375,6 +13810,14 @@ }, "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { + "Registry-Casework-Attempt": { + "description": "Durable attempt UUID when this source mutation was stored but its post-write authoritative read was unavailable.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-present-for-problem-code": "work-item.source-unavailable" + }, "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", "schema": { @@ -11393,12 +13836,12 @@ "bearerAuth": [] } ], - "summary": "Get the next caller-visible item" + "summary": "Recover this exact original attempt" } }, - "/v1/work-items/{item_id}": { - "get": { - "operationId": "getWorkItem", + "/v1/work-items/{item_id}/claim": { + "post": { + "operationId": "claimWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -11433,6 +13876,29 @@ "type": "string" } }, + { + "description": "Quoted positive signed 64-bit Casework item revision.", + "in": "header", + "name": "If-Match", + "required": true, + "schema": { + "maxLength": 21, + "pattern": "^\"[1-9][0-9]{0,18}\"$", + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact mutation. For hosted work, an exact retry after payload expiry and before the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, { "description": "Casework item UUID.", "in": "path", @@ -11449,7 +13915,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkItem" + "$ref": "#/components/schemas/MutationResponse" } } }, @@ -11464,18 +13930,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -11555,6 +14014,58 @@ } } }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, work-item.already-claimed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -11570,30 +14081,30 @@ } } }, - "500": { + "428": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemPreconditionRequired" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: precondition.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -11635,13 +14146,13 @@ "bearerAuth": [] } ], - "summary": "Read one currently visible item" + "summary": "Claim an item" } }, - "/v1/work-items/{item_id}/assign": { - "post": { - "description": "Supervisor-only assignment for a currently served queue. Without Registry-Source-Profile the target is a hosted item; a source-backed item requires the source profile. The assignee is resolved through any active absence cover chain. If no eligible cover is available, the item remains open in its queue with staffingDiagnostic no_cover_available.", - "operationId": "assignWorkItem", + "/v1/work-items/{item_id}/clocks": { + "get": { + "description": "Staff or Supervisor read under the same current source visibility as the item. Registry-Source-Profile is required. Returns occurrences ordered by clockId and clockOccurrenceId with pinned policy digest and separate calculation and recompute generations.", + "operationId": "getWorkItemClocks", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -11668,34 +14179,11 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": false, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, - { - "description": "Quoted positive signed 64-bit Casework item revision.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", - "type": "string" - } - }, - { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", - "in": "header", - "name": "Idempotency-Key", "required": true, "schema": { "maxLength": 128, "minLength": 1, - "pattern": "^[!-~]+$", + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, @@ -11710,22 +14198,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AssignmentRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MutationResponse" + "$ref": "#/components/schemas/ClockOccurrenceList" } } }, @@ -11740,11 +14218,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileRequired" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -11777,9 +14262,6 @@ "application/problem+json": { "schema": { "oneOf": [ - { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" - }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, @@ -11790,7 +14272,7 @@ } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -11831,41 +14313,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" - } - ] - } - } - }, - "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", - "headers": { - "Registry-Casework-Attempt": { - "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", - "schema": { - "format": "uuid", - "type": "string" - }, - "x-required-for-problem-code": "work-item.recovery-pending" - }, - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: work-item.proposal-changed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -11887,51 +14339,6 @@ } } }, - "415": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" - } - } - }, - "description": "Problem response: request.unsupported-media-type", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "422": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - } - }, - "description": "Problem response: request.unprocessable", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "428": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" - } - } - }, - "description": "Problem response: precondition.required", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "500": { "content": { "application/problem+json": { @@ -11997,12 +14404,12 @@ "bearerAuth": [] } ], - "summary": "Assign an item" + "summary": "Read the item's clock occurrences" } }, - "/v1/work-items/{item_id}/attempts/recover": { + "/v1/work-items/{item_id}/decisions": { "post": { - "operationId": "recoverAttemptByKey", + "operationId": "decideWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -12038,12 +14445,13 @@ } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", + "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", + "in": "header", + "name": "If-Match", "required": true, "schema": { - "format": "uuid", + "maxLength": 21, + "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", "type": "string" } }, @@ -12058,13 +14466,23 @@ "pattern": "^[!-~]+$", "type": "string" } + }, + { + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } } ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoverAttemptRequest" + "$ref": "#/components/schemas/DecideRequest" } } }, @@ -12139,12 +14557,15 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemSourceReviewerNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, source.reviewer-not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12155,11 +14576,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceRecordMissing" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: source.record-missing, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12189,14 +14617,26 @@ { "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" + }, { "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSuperseded" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", + "description": "Problem response: idempotency.key-reused, work-item.not-holder, work-item.not-offered, work-item.proposal-changed, work-item.recovery-pending, work-item.superseded", "headers": { "Registry-Casework-Attempt": { "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", @@ -12211,6 +14651,21 @@ } } }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -12245,11 +14700,36 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestReasonUnsupported" + }, + { + "$ref": "#/components/schemas/ProblemRequestSourceRejected" + }, + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + ] } } }, - "description": "Problem response: request.unprocessable", + "description": "Problem response: request.reason-unsupported, request.source-rejected, request.unprocessable", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12329,12 +14809,13 @@ "bearerAuth": [] } ], - "summary": "Recover the original attempt selected by idempotency key" + "summary": "Perform a freshly authorized source action" } }, - "/v1/work-items/{item_id}/attempts/{attempt_id}/recover": { + "/v1/work-items/{item_id}/delegate": { "post": { - "operationId": "recoverAttempt", + "description": "The current Staff holder delegates an item. Without Registry-Source-Profile the target is a hosted item; a source-backed item requires the source profile. Current item visibility, holder, revision, queue eligibility, and any live source attempt are checked before mutation.", + "operationId": "delegateWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -12361,7 +14842,7 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": true, + "required": false, "schema": { "maxLength": 128, "minLength": 1, @@ -12370,19 +14851,32 @@ } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", + "description": "Quoted positive signed 64-bit Casework item revision.", + "in": "header", + "name": "If-Match", "required": true, "schema": { - "format": "uuid", + "maxLength": 21, + "pattern": "^\"[1-9][0-9]{0,18}\"$", "type": "string" } }, { - "description": "Original durable attempt UUID.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "Casework item UUID.", "in": "path", - "name": "attempt_id", + "name": "item_id", "required": true, "schema": { "format": "uuid", @@ -12394,7 +14888,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RecoverAttemptRequest" + "$ref": "#/components/schemas/DelegateRequest" } } }, @@ -12420,18 +14914,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12464,6 +14951,9 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, @@ -12474,7 +14964,7 @@ } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12541,6 +15031,21 @@ } } }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "413": { "content": { "application/problem+json": { @@ -12586,6 +15091,21 @@ } } }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "500": { "content": { "application/problem+json": { @@ -12633,14 +15153,6 @@ }, "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { - "Registry-Casework-Attempt": { - "description": "Durable attempt UUID when this source mutation was stored but its post-write authoritative read was unavailable.", - "schema": { - "format": "uuid", - "type": "string" - }, - "x-present-for-problem-code": "work-item.source-unavailable" - }, "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", "schema": { @@ -12659,12 +15171,12 @@ "bearerAuth": [] } ], - "summary": "Recover this exact original attempt" + "summary": "Delegate a held item" } }, - "/v1/work-items/{item_id}/claim": { - "post": { - "operationId": "claimWorkItem", + "/v1/work-items/{item_id}/draft": { + "delete": { + "operationId": "deleteDraft", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -12691,7 +15203,7 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": false, + "required": true, "schema": { "maxLength": 128, "minLength": 1, @@ -12711,7 +15223,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation. For hosted work, an exact retry after payload expiry and before the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -12734,14 +15246,7 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MutationResponse" - } - } - }, + "204": { "description": "Success", "headers": { "traceparent": { @@ -12753,11 +15258,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemSourceProfileRequired" + } + ] } } }, - "description": "Problem response: request.invalid", + "description": "Problem response: request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12846,58 +15358,73 @@ "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, { - "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + "$ref": "#/components/schemas/ProblemWorkItemNotHolder" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.already-claimed", + "description": "Problem response: idempotency.key-reused, work-item.not-holder", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "410": { + "412": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" + "$ref": "#/components/schemas/ProblemPreconditionFailed" } } }, - "description": "Problem response: idempotency.expired", + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "412": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "415": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: request.unsupported-media-type", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -12969,13 +15496,10 @@ "bearerAuth": [] } ], - "summary": "Claim an item" - } - }, - "/v1/work-items/{item_id}/clocks": { + "summary": "Delete the current actor's private draft" + }, "get": { - "description": "Staff or Supervisor read under the same current source visibility as the item. Registry-Source-Profile is required. Returns occurrences ordered by clockId and clockOccurrenceId with pinned policy digest and separate calculation and recompute generations.", - "operationId": "getWorkItemClocks", + "operationId": "getDraft", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -13026,7 +15550,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClockOccurrenceList" + "$ref": "#/components/schemas/DraftResponse" } } }, @@ -13132,21 +15656,6 @@ } } }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" - } - } - }, - "description": "Problem response: work-item.proposal-changed", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "413": { "content": { "application/problem+json": { @@ -13227,12 +15736,10 @@ "bearerAuth": [] } ], - "summary": "Read the item's clock occurrences" - } - }, - "/v1/work-items/{item_id}/decisions": { - "post": { - "operationId": "decideWorkItem", + "summary": "Read the current actor's private draft" + }, + "put": { + "operationId": "saveDraft", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -13268,13 +15775,13 @@ } }, { - "description": "Quoted nonnegative signed 64-bit Casework item or directory revision.", + "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", "name": "If-Match", "required": true, "schema": { "maxLength": 21, - "pattern": "^\"(0|[1-9][0-9]{0,18})\"$", + "pattern": "^\"[1-9][0-9]{0,18}\"$", "type": "string" } }, @@ -13305,7 +15812,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DecideRequest" + "$ref": "#/components/schemas/SaveDraftRequest" } } }, @@ -13316,7 +15823,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MutationResponse" + "$ref": "#/components/schemas/DraftResponse" } } }, @@ -13380,15 +15887,12 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" - }, - { - "$ref": "#/components/schemas/ProblemSourceReviewerNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, source.reviewer-not-authorized", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -13399,18 +15903,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemSourceRecordMissing" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" - } - ] + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } } }, - "description": "Problem response: source.record-missing, work-item.not-visible", + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -13442,33 +15939,13 @@ }, { "$ref": "#/components/schemas/ProblemWorkItemNotHolder" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemNotOffered" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSuperseded" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.not-holder, work-item.not-offered, work-item.proposal-changed, work-item.recovery-pending, work-item.superseded", + "description": "Problem response: idempotency.key-reused, work-item.not-holder", "headers": { - "Registry-Casework-Attempt": { - "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", - "schema": { - "format": "uuid", - "type": "string" - }, - "x-required-for-problem-code": "work-item.recovery-pending" - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -13523,21 +16000,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestReasonUnsupported" - }, - { - "$ref": "#/components/schemas/ProblemRequestSourceRejected" - }, - { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" - } - ] + "$ref": "#/components/schemas/ProblemRequestUnprocessable" } } }, - "description": "Problem response: request.reason-unsupported, request.source-rejected, request.unprocessable", + "description": "Problem response: request.unprocessable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -13574,21 +16041,6 @@ } } }, - "502": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" - } - } - }, - "description": "Problem response: source.bad-gateway", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, "503": { "content": { "application/problem+json": { @@ -13606,14 +16058,6 @@ }, "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { - "Registry-Casework-Attempt": { - "description": "Durable attempt UUID when this source mutation was stored but its post-write authoritative read was unavailable.", - "schema": { - "format": "uuid", - "type": "string" - }, - "x-present-for-problem-code": "work-item.source-unavailable" - }, "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", "schema": { @@ -13632,13 +16076,13 @@ "bearerAuth": [] } ], - "summary": "Perform a freshly authorized source action" + "summary": "Save the current actor's private draft" } }, - "/v1/work-items/{item_id}/delegate": { - "post": { - "description": "The current Staff holder delegates an item. Without Registry-Source-Profile the target is a hosted item; a source-backed item requires the source profile. Current item visibility, holder, revision, queue eligibility, and any live source attempt are checked before mutation.", - "operationId": "delegateWorkItem", + "/v1/work-items/{item_id}/history": { + "get": { + "description": "Staff or Supervisor only under current source visibility. Results are ordered by occurredAt and eventId.", + "operationId": "getHistory", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -13665,7 +16109,7 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": false, + "required": true, "schema": { "maxLength": 128, "minLength": 1, @@ -13674,55 +16118,42 @@ } }, { - "description": "Quoted positive signed 64-bit Casework item revision.", - "in": "header", - "name": "If-Match", + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", "required": true, "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", + "format": "uuid", "type": "string" } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", - "in": "header", - "name": "Idempotency-Key", - "required": true, + "description": "Opaque 15-minute cursor bound to the human principal, selected Casework profile, selected source profile, and item. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by eventId.", + "in": "query", + "name": "cursor", + "required": false, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[!-~]+$", "type": "string" } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", - "required": true, + "description": "Page size from 1 through 100; values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, "schema": { - "format": "uuid", - "type": "string" + "maximum": 100, + "minimum": 1, + "type": "integer" } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DelegateRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MutationResponse" + "$ref": "#/components/schemas/HistoryPage" } } }, @@ -13734,195 +16165,127 @@ } }, "400": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestInvalid" - } - } - }, - "description": "Problem response: request.invalid", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "401": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemAuthenticationRefused" - } - } - }, - "description": "Problem response: authentication.refused", - "headers": { - "WWW-Authenticate": { - "description": "Bearer authentication challenge.", - "schema": { - "const": "Bearer" - } - }, - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "403": { "content": { "application/problem+json": { "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemCursorInvalid" }, { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemSourceProfileRequired" } ] } } }, - "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "404": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" - } - } - }, - "description": "Problem response: work-item.not-visible", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "405": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" - } - } - }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: cursor.invalid, request.invalid, source-profile.required", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "409": { - "content": { - "application/problem+json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" - } - ] + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" } } }, - "description": "Problem response: idempotency.key-reused, work-item.recovery-pending", + "description": "Problem response: authentication.refused", "headers": { - "Registry-Casework-Attempt": { - "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", "schema": { - "format": "uuid", - "type": "string" - }, - "x-required-for-problem-code": "work-item.recovery-pending" + "const": "Bearer" + } }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "412": { + "403": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemProfileNotHuman" + } + ] } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "413": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "415": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: request.unsupported-media-type", + "description": "Problem response: request.method-not-allowed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "422": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: request.unprocessable", + "description": "Problem response: cursor.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "428": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -13994,12 +16357,13 @@ "bearerAuth": [] } ], - "summary": "Delegate a held item" + "summary": "Read paginated item history" } }, - "/v1/work-items/{item_id}/draft": { - "delete": { - "operationId": "deleteDraft", + "/v1/work-items/{item_id}/hosted-decisions": { + "post": { + "description": "A configured human Staff or Supervisor profile only. Current team service, current holder, selected deciding profile, item revision, and the item's pinned outcome policy are rechecked atomically.", + "operationId": "decideHostedWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -14022,18 +16386,6 @@ "type": "string" } }, - { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", - "in": "header", - "name": "Registry-Source-Profile", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, { "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", @@ -14046,7 +16398,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -14068,8 +16420,25 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostedDecisionRequest" + } + } + }, + "required": true + }, "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HostedTerminalResult" + } + } + }, "description": "Success", "headers": { "traceparent": { @@ -14086,14 +16455,40 @@ "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" } ] } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: request.invalid, source-profile.not-applicable", "headers": { + "Registry-Casework-Validation-Path": { + "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", + "schema": { + "maxLength": 256, + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, + "Registry-Casework-Validation-Reason": { + "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", + "schema": { + "enum": [ + "kind_not_allowed", + "reference_invalid", + "object_required", + "maximum_bytes_exceeded", + "maximum_depth_exceeded", + "schema_mismatch", + "outcome_not_declared", + "reason_required", + "text_invalid" + ], + "type": "string" + }, + "x-present-for-problem-code": "request.invalid" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -14130,12 +16525,15 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14180,14 +16578,35 @@ { "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, + { + "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + }, { "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.not-holder", + "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14288,18 +16707,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemServiceUnavailable" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: service.unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -14319,10 +16731,13 @@ "bearerAuth": [] } ], - "summary": "Delete the current actor's private draft" - }, + "summary": "Record a declared hosted outcome" + } + }, + "/v1/work-items/{item_id}/hosted-history": { "get": { - "operationId": "getDraft", + "description": "Human Staff or Supervisor only. Current deciding-profile and served-queue authority is checked. Results are ordered by occurredAt and eventId and may include requester notes, opaque actor references, outcomes, staff reasons, or cancellation reasons according to the event kind. They never expose requester or raw actor identity.", + "operationId": "getHostedHistory", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -14346,26 +16761,34 @@ } }, { - "description": "Explicit source profile used for the caller-scoped BReg read or action.", - "in": "header", - "name": "Registry-Source-Profile", + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", "required": true, "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", + "format": "uuid", "type": "string" } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", - "required": true, + "description": "Opaque 15-minute cursor bound to the human principal, profile, item, and hosted history feed.", + "in": "query", + "name": "cursor", + "required": false, "schema": { - "format": "uuid", "type": "string" } + }, + { + "description": "Page size from 1 through 100; values outside that range are request.invalid.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } } ], "responses": { @@ -14373,7 +16796,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DraftResponse" + "$ref": "#/components/schemas/HostedHistoryPage" } } }, @@ -14389,17 +16812,20 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + }, { "$ref": "#/components/schemas/ProblemRequestInvalid" }, { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" + "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" } ] } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14437,12 +16863,15 @@ }, { "$ref": "#/components/schemas/ProblemProfileNotHuman" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14479,45 +16908,45 @@ } } }, - "413": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: cursor.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "502": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemSourceBadGateway" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: source.bad-gateway", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14528,18 +16957,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemServiceUnavailable" - }, - { - "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" - } - ] + "$ref": "#/components/schemas/ProblemServiceUnavailable" } } }, - "description": "Problem response: service.unavailable, work-item.source-unavailable", + "description": "Problem response: service.unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -14559,10 +16981,13 @@ "bearerAuth": [] } ], - "summary": "Read the current actor's private draft" - }, - "put": { - "operationId": "saveDraft", + "summary": "List retained hosted lifecycle history" + } + }, + "/v1/work-items/{item_id}/release": { + "post": { + "description": "Staff release only an item they currently hold. A human Supervisor may force-release a held item for a queue served by a team they currently supervise, regardless of its holder. The source-backed form requires Registry-Source-Profile and a successful current source read; the hosted form omits that header. If-Match, Idempotency-Key, current visibility, queue authority, and any live source-attempt fence are checked before the mutation. Success clears the holder and assignment, returns the item to open, and records the previous holder in protected lifecycle history. Supervisor authority does not grant hosted decision authority.", + "operationId": "releaseWorkItem", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -14589,7 +17014,7 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": true, + "required": false, "schema": { "maxLength": 128, "minLength": 1, @@ -14609,7 +17034,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this exact mutation.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation. For hosted work, an exact retry after payload expiry and before the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -14631,22 +17056,12 @@ } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SaveDraftRequest" - } - } - }, - "required": true - }, "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DraftResponse" + "$ref": "#/components/schemas/MutationResponse" } } }, @@ -14661,18 +17076,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.required", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14774,60 +17182,45 @@ } } }, - "412": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" - } - } - }, - "description": "Problem response: precondition.failed", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "413": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + "$ref": "#/components/schemas/ProblemIdempotencyExpired" } } }, - "description": "Problem response: request.body-too-large", + "description": "Problem response: idempotency.expired", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "415": { + "412": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + "$ref": "#/components/schemas/ProblemPreconditionFailed" } } }, - "description": "Problem response: request.unsupported-media-type", + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "422": { + "413": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestUnprocessable" + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" } } }, - "description": "Problem response: request.unprocessable", + "description": "Problem response: request.body-too-large", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -14899,13 +17292,13 @@ "bearerAuth": [] } ], - "summary": "Save the current actor's private draft" + "summary": "Release an item" } }, - "/v1/work-items/{item_id}/history": { + "/v1/work-items/{item_id}/task-grants": { "get": { - "description": "Staff or Supervisor only under current source visibility. Results are ordered by occurredAt and eventId.", - "operationId": "getHistory", + "description": "Metadata includes immutable bounds, deadline, and recorded invalidation. It contains no stored selectors and does not establish current usability.", + "operationId": "listTaskGrants", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -14949,26 +17342,6 @@ "format": "uuid", "type": "string" } - }, - { - "description": "Opaque 15-minute cursor bound to the human principal, selected Casework profile, selected source profile, and item. Malformed, unknown, or context-mismatched values are cursor.invalid. On cursor.expired, restart without it and deduplicate by eventId.", - "in": "query", - "name": "cursor", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" - } } ], "responses": { @@ -14976,7 +17349,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HistoryPage" + "$ref": "#/components/schemas/TaskGrantList" } } }, @@ -14991,21 +17364,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileRequired" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.required", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15038,6 +17401,9 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, @@ -15048,7 +17414,7 @@ } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15059,11 +17425,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15085,15 +17458,48 @@ } } }, - "410": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", + "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15180,13 +17586,11 @@ "bearerAuth": [] } ], - "summary": "Read paginated item history" - } - }, - "/v1/work-items/{item_id}/hosted-decisions": { + "summary": "List task grant metadata" + }, "post": { - "description": "A configured human Staff or Supervisor profile only. Current team service, current holder, selected deciding profile, item revision, and the item's pinned outcome policy are rechecked atomically.", - "operationId": "decideHostedWorkItem", + "description": "Only a configured template id and version are accepted. Current holder, team, profile, source disclosure and proposal are rechecked. Reusing the same idempotency key returns the original grant without extending its deadline; changed bounds conflict.", + "operationId": "approveTaskGrant", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -15209,6 +17613,18 @@ "type": "string" } }, + { + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", + "type": "string" + } + }, { "description": "Quoted positive signed 64-bit Casework item revision.", "in": "header", @@ -15221,7 +17637,7 @@ } }, { - "description": "Caller-selected ASCII graphic key bound to this principal, selected profile, operation, resource, and exact request. After the item payload expires, an exact retry until the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", + "description": "Caller-selected ASCII graphic key bound to this exact mutation.", "in": "header", "name": "Idempotency-Key", "required": true, @@ -15247,7 +17663,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedDecisionRequest" + "$ref": "#/components/schemas/TaskApprovalRequest" } } }, @@ -15258,7 +17674,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedTerminalResult" + "$ref": "#/components/schemas/TaskGrantView" } } }, @@ -15273,45 +17689,12 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid", "headers": { - "Registry-Casework-Validation-Path": { - "description": "Bounded JSON path for a typed hosted validation failure. Present together with Registry-Casework-Validation-Reason; rejected values are never echoed.", - "schema": { - "maxLength": 256, - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, - "Registry-Casework-Validation-Reason": { - "description": "Stable, value-free reason for a typed hosted validation failure. Present together with Registry-Casework-Validation-Path.", - "schema": { - "enum": [ - "kind_not_allowed", - "reference_invalid", - "object_required", - "maximum_bytes_exceeded", - "maximum_depth_exceeded", - "schema_mismatch", - "outcome_not_declared", - "reason_required", - "text_invalid" - ], - "type": "string" - }, - "x-present-for-problem-code": "request.invalid" - }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -15344,19 +17727,19 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15367,11 +17750,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15402,35 +17792,28 @@ "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" }, { - "$ref": "#/components/schemas/ProblemWorkItemAlreadyClaimed" + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" }, { - "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" }, { - "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" } - ] - } - } - }, - "description": "Problem response: idempotency.key-reused, work-item.already-claimed, work-item.not-holder, work-item.not-offered", - "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "410": { - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" + ] } } }, - "description": "Problem response: idempotency.expired", + "description": "Problem response: idempotency.key-reused, work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -15526,15 +17909,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -15554,13 +17959,12 @@ "bearerAuth": [] } ], - "summary": "Record a declared hosted outcome" + "summary": "Approve an immutable task grant" } }, - "/v1/work-items/{item_id}/hosted-history": { - "get": { - "description": "Human Staff or Supervisor only. Current deciding-profile and served-queue authority is checked. Results are ordered by occurredAt and eventId and may include requester notes, opaque actor references, outcomes, staff reasons, or cancellation reasons according to the event kind. They never expose requester or raw actor identity.", - "operationId": "getHostedHistory", + "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke": { + "post": { + "operationId": "revokeTaskGrant", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -15584,33 +17988,35 @@ } }, { - "description": "Casework item UUID.", - "in": "path", - "name": "item_id", + "description": "Explicit source profile used for the caller-scoped BReg read or action.", + "in": "header", + "name": "Registry-Source-Profile", "required": true, "schema": { - "format": "uuid", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, { - "description": "Opaque 15-minute cursor bound to the human principal, profile, item, and hosted history feed.", - "in": "query", - "name": "cursor", - "required": false, + "description": "Casework item UUID.", + "in": "path", + "name": "item_id", + "required": true, "schema": { + "format": "uuid", "type": "string" } }, { - "description": "Page size from 1 through 100; values outside that range are request.invalid.", - "in": "query", - "name": "limit", - "required": false, + "description": "Immutable task grant identifier.", + "in": "path", + "name": "grant_id", + "required": true, "schema": { - "maximum": 100, - "minimum": 1, - "type": "integer" + "format": "uuid", + "type": "string" } } ], @@ -15619,7 +18025,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HostedHistoryPage" + "$ref": "#/components/schemas/TaskGrantRevocation" } } }, @@ -15634,21 +18040,11 @@ "content": { "application/problem+json": { "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProblemCursorInvalid" - }, - { - "$ref": "#/components/schemas/ProblemRequestInvalid" - }, - { - "$ref": "#/components/schemas/ProblemSourceProfileNotApplicable" - } - ] + "$ref": "#/components/schemas/ProblemRequestInvalid" } } }, - "description": "Problem response: cursor.invalid, request.invalid, source-profile.not-applicable", + "description": "Problem response: request.invalid", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15682,19 +18078,19 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemProfileNotHuman" + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, { - "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + "$ref": "#/components/schemas/ProblemProfileNotHuman" } ] } } }, - "description": "Problem response: profile.not-authorized, profile.not-human, operation.not-authorized", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15705,11 +18101,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15731,15 +18134,48 @@ } } }, - "410": { + "409": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" + } + ] } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", + "headers": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", + "schema": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15776,15 +18212,37 @@ } } }, + "502": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemSourceBadGateway" + } + } + }, + "description": "Problem response: source.bad-gateway", + "headers": { + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "503": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemServiceUnavailable" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemSourceUnavailable" + } + ] } } }, - "description": "Problem response: service.unavailable", + "description": "Problem response: service.unavailable, work-item.source-unavailable", "headers": { "Retry-After": { "description": "Seconds before retrying the unavailable dependency.", @@ -15804,13 +18262,13 @@ "bearerAuth": [] } ], - "summary": "List retained hosted lifecycle history" + "summary": "Revoke a task grant" } }, - "/v1/work-items/{item_id}/release": { - "post": { - "description": "Staff release only an item they currently hold. A human Supervisor may force-release a held item for a queue served by a team they currently supervise, regardless of its holder. The source-backed form requires Registry-Source-Profile and a successful current source read; the hosted form omits that header. If-Match, Idempotency-Key, current visibility, queue authority, and any live source-attempt fence are checked before the mutation. Success clears the holder and assignment, returns the item to open, and records the previous holder in protected lifecycle history. Supervisor authority does not grant hosted decision authority.", - "operationId": "releaseWorkItem", + "/v1/work-items/{item_id}/task-templates": { + "get": { + "description": "Current holder and Directory eligibility are checked. Subjects come from the caller's current disclosed source read. Preview grants no authority; approval recomputes it.", + "operationId": "previewTaskTemplates", "parameters": [ { "description": "Optional W3C trace context continued in the response.", @@ -15837,34 +18295,11 @@ "description": "Explicit source profile used for the caller-scoped BReg read or action.", "in": "header", "name": "Registry-Source-Profile", - "required": false, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, - { - "description": "Quoted positive signed 64-bit Casework item revision.", - "in": "header", - "name": "If-Match", - "required": true, - "schema": { - "maxLength": 21, - "pattern": "^\"[1-9][0-9]{0,18}\"$", - "type": "string" - } - }, - { - "description": "Caller-selected ASCII graphic key bound to this exact mutation. For hosted work, an exact retry after payload expiry and before the accountability retention deadline returns idempotency.expired; a changed request remains idempotency.key-reused. After that deadline the record is forgotten and the key may be reused.", - "in": "header", - "name": "Idempotency-Key", "required": true, "schema": { "maxLength": 128, "minLength": 1, - "pattern": "^[!-~]+$", + "pattern": "^[A-Za-z0-9_.:-]+$", "type": "string" } }, @@ -15884,7 +18319,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MutationResponse" + "$ref": "#/components/schemas/TaskTemplatePreviews" } } }, @@ -15936,6 +18371,9 @@ "application/problem+json": { "schema": { "oneOf": [ + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + }, { "$ref": "#/components/schemas/ProblemProfileNotAuthorized" }, @@ -15946,7 +18384,7 @@ } } }, - "description": "Problem response: profile.not-authorized, profile.not-human", + "description": "Problem response: operation.not-authorized, profile.not-authorized, profile.not-human", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15957,11 +18395,18 @@ "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemSourceNotFound" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemNotVisible" + } + ] } } }, - "description": "Problem response: work-item.not-visible", + "description": "Problem response: source.not-found, work-item.not-visible", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -15989,32 +18434,28 @@ "schema": { "oneOf": [ { - "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + "$ref": "#/components/schemas/ProblemWorkItemProposalChanged" }, { - "$ref": "#/components/schemas/ProblemWorkItemNotHolder" + "$ref": "#/components/schemas/ProblemWorkItemNotOffered" + }, + { + "$ref": "#/components/schemas/ProblemWorkItemRecoveryPending" } ] } } }, - "description": "Problem response: idempotency.key-reused, work-item.not-holder", + "description": "Problem response: work-item.proposal-changed, work-item.not-offered, work-item.recovery-pending", "headers": { - "traceparent": { - "$ref": "#/components/headers/TraceparentHeader" - } - } - }, - "410": { - "content": { - "application/problem+json": { + "Registry-Casework-Attempt": { + "description": "Original attempt UUID, present only when the entitled recovery-pending response can disclose it.", "schema": { - "$ref": "#/components/schemas/ProblemIdempotencyExpired" - } - } - }, - "description": "Problem response: idempotency.expired", - "headers": { + "format": "uuid", + "type": "string" + }, + "x-required-for-problem-code": "work-item.recovery-pending" + }, "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } @@ -16050,30 +18491,30 @@ } } }, - "428": { + "500": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionRequired" + "$ref": "#/components/schemas/ProblemRuntimeFailure" } } }, - "description": "Problem response: precondition.required", + "description": "Problem response: runtime.failure", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" } } }, - "500": { + "502": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRuntimeFailure" + "$ref": "#/components/schemas/ProblemSourceBadGateway" } } }, - "description": "Problem response: runtime.failure", + "description": "Problem response: source.bad-gateway", "headers": { "traceparent": { "$ref": "#/components/headers/TraceparentHeader" @@ -16115,7 +18556,7 @@ "bearerAuth": [] } ], - "summary": "Release an item" + "summary": "Preview eligible task authorization" } } }, diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index ffca3fd90b..4ef83264be 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -13,6 +13,13 @@ ROUTES = { + "/.well-known/jwks.json", + "/v1/task-grants/{grant_id}/assertion", + "/v1/task-grants/{grant_id}/status", + "/v1/work-items/{item_id}/task-grants", + "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke", + "/v1/work-items/{item_id}/task-templates", + "/health", "/ready", "/v1/casework", @@ -83,6 +90,19 @@ "HostedHistoryPage", } SCHEMA_STRUCTS = { + "crates/registry-casework-core/src/task_grant.rs": { + "TaskTemplate": "TaskTemplate", + "TaskTemplatePreview": "TaskTemplatePreview", + "TaskTemplatePreviews": "TaskTemplatePreviews", + "TaskPermission": "TaskPermission", + "TaskApprovalRequest": "TaskApprovalRequest", + "TaskGrantView": "TaskGrantView", + "TaskGrantList": "TaskGrantList", + "TaskGrantRevocation": "TaskGrantRevocation", + "TaskAssertionResponse": "TaskAssertionResponse", + "TaskGrantStatus": "TaskGrantStatus", + "TaskGrantStatusDetails": "TaskGrantStatusDetails", + }, "crates/registry-casework-core/src/model.rs": { "IssuerPrincipal": "IssuerPrincipal", "DirectoryMember": "DirectoryMember", @@ -176,6 +196,14 @@ }, } OPERATION_IDS = { + ("GET", "/.well-known/jwks.json"): "getTaskAuthorityKeys", + ("GET", "/v1/work-items/{item_id}/task-templates"): "previewTaskTemplates", + ("GET", "/v1/work-items/{item_id}/task-grants"): "listTaskGrants", + ("POST", "/v1/work-items/{item_id}/task-grants"): "approveTaskGrant", + ("POST", "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke"): "revokeTaskGrant", + ("POST", "/v1/task-grants/{grant_id}/assertion"): "getTaskAssertion", + ("GET", "/v1/task-grants/{grant_id}/status"): "getTaskGrantStatus", + ("POST", "/events/sources/{source_id}"): "acceptSourceEvent", ("GET", "/health"): "health", ("GET", "/ready"): "readiness", @@ -742,7 +770,7 @@ def schemas(problem_entries: list[dict]) -> dict: ), "MutationResponse": obj({"item": ref("WorkItem"), "attempt": nullable(ref("AttemptStatus"))}, ["item"]), "HistoryEntry": obj( - {"eventId": uuid, "itemId": uuid, "itemRevision": integer, "kind": {"type": "string", "enum": ["observed", "opened", "claimed", "assigned", "delegated", "caseload_moved", "clock_reminder", "clock_step_applied", "clock_recomputed", "released", "draft_saved", "attempt_reserved", "attempt_uncertain", "action_completed", "attempt_settled", "superseded", "completed"]}, "occurredAt": instant, "actor": nullable(ref("IssuerPrincipal")), "profileId": text, "detail": {}}, + {"eventId": uuid, "itemId": uuid, "itemRevision": integer, "kind": {"type": "string", "enum": ["observed", "opened", "claimed", "assigned", "delegated", "caseload_moved", "clock_reminder", "clock_step_applied", "clock_recomputed", "released", "draft_saved", "attempt_reserved", "attempt_uncertain", "action_completed", "attempt_settled", "superseded", "completed", "task_approved", "task_revoked", "task_invalidated"]}, "occurredAt": instant, "actor": nullable(ref("IssuerPrincipal")), "profileId": text, "detail": {}}, ["eventId", "itemId", "itemRevision", "kind", "occurredAt", "profileId", "detail"], ), "HistoryPage": obj({"items": array(ref("HistoryEntry")), "nextCursor": nullable(text), "status": {"const": "complete"}}, ["items", "status"]), @@ -1142,6 +1170,8 @@ def schemas(problem_entries: list[dict]) -> dict: ), "Problem": obj({"type": {"type": "string", "format": "uri"}, "title": text, "status": {"type": "integer"}, "detail": text, "code": {"type": "string", "enum": [entry["code"] for entry in problem_entries]}, "traceId": text}, ["type", "title", "status", "detail", "code", "traceId"]), } + result.update(task_schemas()) + result["CaseworkProject"]["properties"]["taskTemplates"] = {"type":"array", "maxItems":64, "items":ref("TaskTemplate")} result.update( { problem_component_name(entry["code"]): problem_variant_schema(entry) @@ -1151,6 +1181,35 @@ def schemas(problem_entries: list[dict]) -> dict: return result +def task_schemas() -> dict: + text = {"type":"string", "minLength":1, "maxLength":512} + number = {"type":"integer", "minimum":0} + uuid = {"type":"string", "format":"uuid"} + subjects = {"type":"object", "maxProperties":32, "additionalProperties":{"type":["string","integer","boolean"]}} + permission = obj({"collection":text, "operations":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":text}}, ["collection","operations"]) + common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "purpose":text, "bounds":ref("TaskGrantBounds")} + preview = {"id":text, "version":text, "label":text, **common, "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} + template = {**preview, "eligibleTeams":array(text), "eligibleProfiles":array(text), "source":text, "itemKinds":array(text), "itemStates":{"type":"array","items":{"enum":["claimed","waiting_applicant","waiting_application"]}}} + template["subjects"] = {"type":"object", "minProperties":1, "maxProperties":32, "additionalProperties":text} + view = {"id":uuid, "templateId":text, "templateVersion":text, **common, "expiresAt":number, "invalidated":{"type":"boolean"}} + details = {"grantId":uuid, "authority":text, "sourceIssuer":text, "principal":text, "client":text, "resource":text, "purpose":text, "bounds":ref("TaskGrantBounds"), "subjects":subjects, "expiresAt":number} + return { + "TaskTemplate":obj(template,list(template)), + "TaskTemplatePreview":obj(preview,list(preview)), + "TaskTemplatePreviews":obj({"itemRevision":number,"templates":array(ref("TaskTemplatePreview"))},["itemRevision","templates"]), + "TaskPermission":permission, + "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"])]}, + "TaskApprovalRequest":obj({"templateId":text,"templateVersion":text},["templateId","templateVersion"]), + "TaskGrantView":obj(view,list(view)), + "TaskGrantList":obj({"grants":{"type":"array","maxItems":128,"items":ref("TaskGrantView")}},["grants"]), + "TaskGrantRevocation":obj({"id":uuid,"invalidated":{"type":"boolean"}},["id","invalidated"]), + "TaskAssertionResponse":obj({"assertion":{"type":"string","description":"Sensitive short-lived credential. Do not log or persist."},"expiresAt":number,"grantExpiresAt":number},["assertion","expiresAt","grantExpiresAt"]), + "TaskGrantStatusDetails":obj(details,list(details)), + "TaskGrantStatus":obj({"active":{"type":"boolean"},"grant":ref("TaskGrantStatusDetails")},["active"]), + "TaskAuthorityJwks":obj({"keys":{"type":"array","items":{"type":"object"}}},["keys"]), + } + + def parameter(name: str, where: str, description: str, schema: dict | None = None, required: bool = True) -> dict: return {"name": name, "in": where, "required": required, "description": description, "schema": schema or {"type": "string"}} @@ -1491,6 +1550,18 @@ def document(contract: dict) -> dict: {"type": "object", "properties": {"items": {"maxItems": 1}}}, ] } + grant_id = parameter("grant_id", "path", "Immutable task grant identifier.", {"type":"string","format":"uuid"}) + paths.update({ + "/.well-known/jwks.json":{"get":{"summary":"Task authority public verification keys", "security":[], "parameters":[TRACEPARENT], "responses":{"200":response("TaskAuthorityJwks")}}}, + "/v1/work-items/{item_id}/task-templates":{"get":operation("Preview eligible task authorization", "TaskTemplatePreviews", source=True, parameters=[ITEM_ID], description="Current holder and Directory eligibility are checked. Subjects come from the caller's current disclosed source read. Preview grants no authority; approval recomputes it.")}, + "/v1/work-items/{item_id}/task-grants":{"get":operation("List task grant metadata", "TaskGrantList", source=True, parameters=[ITEM_ID], description="Metadata includes immutable bounds, deadline, and recorded invalidation. It contains no stored selectors and does not establish current usability."), "post":operation("Approve an immutable task grant", "TaskGrantView", source=True, mutation=True, body="TaskApprovalRequest", parameters=[ITEM_ID], description="Only a configured template id and version are accepted. Current holder, team, profile, source disclosure and proposal are rechecked. Reusing the same idempotency key returns the original grant without extending its deadline; changed bounds conflict.")}, + "/v1/work-items/{item_id}/task-grants/{grant_id}/revoke":{"post":operation("Revoke a task grant", "TaskGrantRevocation", source=True, parameters=[ITEM_ID,grant_id])}, + "/v1/task-grants/{grant_id}/assertion":{"post":operation("Issue a short-lived task assertion", "TaskAssertionResponse", parameters=[grant_id], description="Requires a bootstrap agent token with casework:grants:assert, exact single Casework audience, and the registered agent principal/client. Profile headers and grant-bearing tokens are refused. Fresh eligibility and source checks precede issuance; assertion lifetime is at most 60 seconds and never exceeds the original grant deadline.")}, + "/v1/task-grants/{grant_id}/status":{"get":operation("Check current resource-bound task authority", "TaskGrantStatus", parameters=[grant_id], description="Requires a service token with casework:grants:status and the registered client for the grant resource. Returns fresh status after Directory and source checks. Consumers must compare every bound and enforce expiresAt. An unavailable check grants no authority.")}, + }) + for path in ["/v1/task-grants/{grant_id}/assertion", "/v1/task-grants/{grant_id}/status"]: + for operation_value in paths[path].values(): + operation_value["parameters"] = [p for p in operation_value["parameters"] if p.get("name") != "registry-casework-profile"] apply_operation_contract(paths, contract, catalog) apply_hosted_validation_headers(paths) result = { @@ -1809,6 +1880,7 @@ def verify_dto_schemas(repository_root: Path, openapi: dict) -> None: "calendars", "clocks", "inbox", + "taskTemplates", }: raise ValueError("OpenAPI authored CaseworkProject shape drifted from Rust") if set(openapi_schemas["Description"]["properties"]) != { From b4dea7fa461316f9fc170855ba6606cbcb364f82 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:51 +0700 Subject: [PATCH 006/120] feat(evidence): checkpoint issuer migration and task authorization Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- .../registry-evidence-client-node/client.d.ts | 16 + .../src/convert.rs | 64 + .../src/convert.rs | 72 + crates/registry-evidence-client/Cargo.toml | 2 +- crates/registry-evidence-client/src/client.rs | 33 +- .../src/private_key_jwt.rs | 5 +- .../registry-evidence-client/src/profile.rs | 143 ++ crates/registry-evidence-oid4vci/Cargo.toml | 2 +- .../src/authorizer.rs | 76 +- .../registry-evidence-oid4vci/src/config.rs | 227 ++- .../registry-evidence-oid4vci/src/issuer.rs | 90 +- .../registry-evidence-oid4vci/src/service.rs | 8 +- .../tests/inji_interoperability.rs | 4 +- .../tests/mint_offer_authorization.rs | 476 +++--- .../tests/state_machine_recovery.rs | 2 +- crates/registry-evidence/src/audit.rs | 82 +- crates/registry-evidence/src/auth.rs | 305 +++- crates/registry-evidence/src/config.rs | 418 ++++- crates/registry-evidence/src/runtime.rs | 89 + crates/registry-evidence/src/runtime_tests.rs | 209 ++- crates/registry-evidence/src/selector.rs | 148 +- crates/registry-evidence/src/source.rs | 19 +- .../tests/selector_conformance.rs | 138 +- .../tests/source_contracts.rs | 113 ++ crates/registry-evidencectl/Cargo.toml | 1 + crates/registry-evidencectl/src/access.rs | 130 +- crates/registry-evidencectl/src/authoring.rs | 33 +- crates/registry-evidencectl/src/client.rs | 89 + crates/registry-evidencectl/src/dev.rs | 805 +++++---- crates/registry-evidencectl/src/lib.rs | 40 +- crates/registry-evidencectl/src/request.rs | 90 +- crates/registry-evidencectl/src/source_add.rs | 264 ++- .../registry-evidencectl/tests/audit_view.rs | 9 +- .../tests/dev_lifecycle.rs | 1444 +++-------------- .../tests/output_format.rs | 4 +- .../tests/production_handoff.rs | 22 +- .../tests/request_verify.rs | 192 ++- products/evidence/CONCEPT.md | 6 +- products/evidence/IMPLEMENTATION.md | 7 +- products/evidence/README.md | 4 +- .../contracts/audit-event.schema.yaml | 10 +- .../contracts/authority-context.schema.yaml | 14 +- .../evidence/contracts/bundle.schema.yaml | 111 +- .../evidence/contracts/oid4vci-profile.yaml | 2 +- .../request-batch-audit-event.schema.yaml | 5 + .../contracts/security-invariant-matrix.yaml | 24 +- .../contracts/security-test-traceability.yaml | 15 +- .../evidence/contracts/selector-contract.yaml | 2 +- .../evidence/contracts/source-contract.yaml | 5 +- .../acceptance/adult-status/evidence.yaml | 2 - .../acceptance/all-definitions/evidence.yaml | 5 +- .../legal-parent-relationship-cases.yaml | 6 +- .../acceptance/holder-bound/evidence.yaml | 5 +- .../legal-parent-relationship-cases.yaml | 6 +- .../legal-parent-relationship/evidence.yaml | 4 +- .../fixtures/cases.yaml | 6 +- .../professional-licence/evidence.yaml | 2 +- .../acceptance/residence-region/evidence.yaml | 2 +- .../surviving-spouse-status/evidence.yaml | 2 +- .../fixtures/conformance/audit-events.yaml | 10 + .../fixtures/conformance/selector-matrix.yaml | 2 - .../conformance/selectors/evidence.yaml | 5 +- .../supported-values/evidence.yaml | 2 - .../local/evidence/governance.yaml | 2 - .../production/evidence/governance.yaml | 2 - .../staging/evidence/governance.yaml | 2 - .../deployment-projects/CONFIG.md | 59 +- .../bundle/evidence.yaml | 2 - .../bundle/evidence.yaml | 5 +- .../registered-parent-relationship-cases.yaml | 2 - .../bundle/evidence.yaml | 2 - .../bundle/evidence.yaml | 2 - 72 files changed, 3688 insertions(+), 2518 deletions(-) diff --git a/crates/registry-evidence-client-node/client.d.ts b/crates/registry-evidence-client-node/client.d.ts index 82fbe35f51..97ccefcd07 100644 --- a/crates/registry-evidence-client-node/client.d.ts +++ b/crates/registry-evidence-client-node/client.d.ts @@ -82,7 +82,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: Readonly> + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number requestTimeoutMs?: number diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index 3843432f50..a2641c8267 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -134,6 +134,33 @@ fn optional_string( } } +/// An optional member that, when present, must be an array of strings. The +/// values' own grammar is the provider's to check; this holds only the shape. +fn optional_string_array( + object: &Map, + field: &str, +) -> Result>, ConversionError> { + let Some(values) = object.get(field) else { + return Ok(None); + }; + if values.is_null() { + return Ok(None); + } + let values = values + .as_array() + .ok_or_else(|| ConversionError::new(format!("`{field}` must be an array of strings")))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("`{field}` must contain only strings"))) + }) + .collect::, _>>() + .map(Some) +} + fn optional_u64(object: &Map, field: &str) -> Result, ConversionError> { match object.get(field) { None | Some(Value::Null) => Ok(None), @@ -617,6 +644,12 @@ fn private_key_jwt_provider_from_json(value: &Value) -> Result, + field: &str, +) -> Result>, ConversionError> { + let Some(values) = object.get(field) else { + return Ok(None); + }; + if values.is_null() { + return Ok(None); + } + let values = values + .as_array() + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a list of strings")))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("`{field}` must contain only strings"))) + }) + .collect::, _>>() + .map(Some) +} + fn optional_i64(object: &Map, field: &str) -> Result, ConversionError> { match object.get(field) { None | Some(Value::Null) => Ok(None), @@ -708,6 +735,12 @@ fn private_key_jwt_provider_from_json(value: &Value) -> Result/private.jwk` and passes it /// here unchanged, and that file is ES256. A binding that took only EdDSA /// would refuse every client `evidencectl` creates. diff --git a/crates/registry-evidence-client/Cargo.toml b/crates/registry-evidence-client/Cargo.toml index 56da3a1926..3c8a92c1be 100644 --- a/crates/registry-evidence-client/Cargo.toml +++ b/crates/registry-evidence-client/Cargo.toml @@ -34,7 +34,7 @@ zeroize.workspace = true ed25519-dalek.workspace = true p256.workspace = true registry-evidence.workspace = true -registry-mint.workspace = true +registry-thunderid-tooling.workspace = true registry-platform-sdjwt.workspace = true tempfile.workspace = true wiremock.workspace = true diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 16615528fc..acd5ff1b20 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -594,20 +594,37 @@ impl EvidenceClient { // revalidation. That must not throw away a still-valid access token // when the revalidated token endpoint is byte-for-byte unchanged. // A changed endpoint gets a new provider before any credential is sent. + // The profile's fixed OAuth parameters travel with that provider, so + // the provider and the parameters cannot drift apart between requests. let token_provider = if let Some(snapshot) = previous.filter(|snapshot| { snapshot.authorization.issuer == authorization.value.issuer && snapshot.authorization.token_endpoint == authorization.value.token_endpoint }) { Arc::clone(&snapshot.token_provider) } else { - Arc::new(PrivateKeyJwt::new( - PrivateKeyJwtConfig::new( - token_endpoint, - state.profile.client_id.clone(), - state.private_key.clone(), - ) - .with_fetch_url_policy(fetch_policy), - )?) as Arc + let mut config = PrivateKeyJwtConfig::new( + token_endpoint, + state.profile.client_id.clone(), + state.private_key.clone(), + ) + .with_fetch_url_policy(fetch_policy); + if let Some(oauth) = &state.profile.oauth { + // The assertion audience, the resource indicator, and the + // requested scopes are the deployment's fixed configuration. + // None of them is derived from the catalog, and the discovery + // token endpoint is never silently substituted for a stated + // assertion audience. + if let Some(audience) = &oauth.client_assertion_audience { + config = config.with_audience(audience.clone()); + } + if let Some(resource) = &oauth.resource { + config = config.with_resource(resource.clone()); + } + if let Some(scopes) = &oauth.scopes { + config = config.with_scopes(scopes.iter().cloned()); + } + } + Arc::new(PrivateKeyJwt::new(config)?) as Arc }; let cache_seconds = protected .cache_seconds diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 75463e8f91..7a59653e08 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -1,7 +1,8 @@ //! Compatibility re-exports for the product-neutral private-key-JWT provider. pub use registry_platform_httputil::{ - PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, + valid_scope_token, PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, - MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, + MAXIMUM_SCOPE_PARAMETER_BYTES, }; diff --git a/crates/registry-evidence-client/src/profile.rs b/crates/registry-evidence-client/src/profile.rs index b0c33083b3..1846c67216 100644 --- a/crates/registry-evidence-client/src/profile.rs +++ b/crates/registry-evidence-client/src/profile.rs @@ -40,6 +40,13 @@ pub struct EvidenceClientProfile { pub verification: VerificationProfile, #[serde(default)] pub expected: ExpectedServiceProfile, + /// Fixed OAuth request parameters for the token acquisition, for issuers + /// whose client-assertion audience, resource indicator, or requested + /// scopes are not derivable from discovery. Absent members keep the + /// discovery-driven behavior; this object never derives authority from + /// the catalog, it only states what the token request carries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth: Option, #[serde(default = "default_metadata_cache_seconds")] pub maximum_metadata_cache_seconds: u64, #[serde(skip)] @@ -90,6 +97,10 @@ impl EvidenceClientProfile { || self.verification.maximum_assertion_lifetime_seconds == 0 || self.verification.maximum_assertion_lifetime_seconds > 31_536_000 || self.verification.clock_skew_seconds > 300 + || self + .oauth + .as_ref() + .is_some_and(|oauth| oauth.validate().is_err()) { return Err(profile_error()); } @@ -404,6 +415,68 @@ pub struct ExpectedServiceProfile { pub provider: Option, } +/// The token-request parameters a profile fixes ahead of discovery. +/// +/// `client_assertion_audience` is who checks the client's authentication JWT +/// (for ThunderID v1.0.1, the issuer string); `resource` is the RFC 8707 +/// resource indicator the resulting token's audience must name. They answer +/// different questions and are never substituted for one another, and neither +/// is the response-verification `expected.audience`, which describes the +/// Evidence assertion's audience instead. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OauthProfile { + #[serde(default)] + pub client_assertion_audience: Option, + #[serde(default)] + pub resource: Option, + #[serde(default)] + pub scopes: Option>, +} + +impl OauthProfile { + /// The members the profile itself can check ahead of the provider's own + /// construction-time validation: shapes, bounds, and the scope-token + /// grammar, so a mis-authored profile fails at load rather than at the + /// first token request. + pub fn validate(&self) -> Result<(), EvidenceClientError> { + let valid = self + .client_assertion_audience + .as_deref() + .is_none_or(valid_expected_identity) + && self.resource.as_deref().is_none_or(|value| { + !value.is_empty() + && value.len() <= MAXIMUM_PROFILE_REFERENCE_BYTES + && url::Url::parse(value).is_ok_and(|url| { + !url.scheme().is_empty() + && url.fragment().is_none() + && url.username().is_empty() + && url.password().is_none() + }) + }) + && self.scopes.as_ref().is_none_or(|scopes| { + !scopes.is_empty() + && scopes.len() <= registry_platform_httputil::MAXIMUM_REQUESTED_SCOPES + && scopes.iter().all(|scope| { + !scope.is_empty() + && scope.len() + <= registry_platform_httputil::MAXIMUM_REQUESTED_SCOPE_BYTES + && registry_platform_httputil::valid_scope_token(scope) + }) + && scopes + .iter() + .collect::>() + .len() + == scopes.len() + }); + if valid { + Ok(()) + } else { + Err(profile_error()) + } + } +} + pub(crate) fn profile_error() -> EvidenceClientError { EvidenceClientError::configuration("the client profile is invalid or unavailable") } @@ -467,6 +540,7 @@ mod tests { verification: VerificationProfile::default(), maximum_metadata_cache_seconds: DEFAULT_METADATA_CACHE_SECONDS, expected: ExpectedServiceProfile::default(), + oauth: None, origin_directory: None, }; profile("https://evidence.example.org") @@ -481,6 +555,72 @@ mod tests { } } + /// The optional `oauth` object fixes the token request's assertion + /// audience, resource, and scopes. It validates against the same grammar + /// the provider enforces, so a mis-authored profile fails at load, and it + /// is absent from a profile that does not state it. + #[test] + fn the_optional_oauth_object_validates_and_round_trips() { + let base = r#"{"schema":"registry.evidence-client-profile/v1","baseUrl":"https://evidence.example.org","clientId":"client","privateKey":{"source":"environment","variable":"EVIDENCE_KEY"}}"#; + // `base` closes the profile object, so splice the oauth object in + // before its closing brace rather than appending after it. + let members = |oauth: &str| { + let mut value = base.to_owned(); + value.pop(); + value.push_str(",\"oauth\":"); + value.push_str(oauth); + value.push('}'); + value + }; + let profile = EvidenceClientProfile::from_slice( + members( + r#"{"clientAssertionAudience":"https://issuer.example.org","resource":"urn:registry:evidence","scopes":["evidence:invoke"]}"#, + ) + .as_bytes(), + ) + .expect("the oauth object validates"); + assert_eq!( + profile + .oauth + .as_ref() + .and_then(|oauth| oauth.resource.clone()), + Some("urn:registry:evidence".to_owned()) + ); + assert!(serde_json::to_string(&profile) + .expect("the profile serializes") + .contains("\"oauth\"")); + assert!( + !serde_json::to_string( + &EvidenceClientProfile::from_slice(base.as_bytes()) + .expect("the plain profile parses") + ) + .expect("the plain profile serializes") + .contains("\"oauth\""), + "an unstated oauth object must not appear in the profile" + ); + + for rejected in [ + // A resource with a fragment or userinfo is not an RFC 8707 + // resource indicator. + r#"{"resource":"urn:registry:evidence#fragment"}"#, + r#"{"resource":"https://user:pw@registry.example.org"}"#, + // Scopes are RFC 6749 scope-tokens, stated at least once, without + // repetition. + r#"{"scopes":[]}"#, + r#"{"scopes":[""]}"#, + r#"{"scopes":["records:read","records:read"]}"#, + r#"{"scopes":["re c"]}"#, + // An empty assertion audience is never useful. + r#"{"clientAssertionAudience":""}"#, + ] { + assert!( + EvidenceClientProfile::from_slice(members(rejected).as_bytes()).is_err(), + "accepted: {}", + members(rejected) + ); + } + } + #[test] fn strict_profiles_refuse_literal_origins_the_fetch_policy_will_deny() { let profile = |base_url: &str, trust: TrustProfile| EvidenceClientProfile { @@ -495,6 +635,7 @@ mod tests { verification: VerificationProfile::default(), maximum_metadata_cache_seconds: DEFAULT_METADATA_CACHE_SECONDS, expected: ExpectedServiceProfile::default(), + oauth: None, origin_directory: None, }; @@ -550,6 +691,7 @@ mod tests { verification: VerificationProfile::default(), maximum_metadata_cache_seconds: DEFAULT_METADATA_CACHE_SECONDS, expected, + oauth: None, origin_directory: None, }; profile(ExpectedServiceProfile { @@ -593,6 +735,7 @@ mod tests { verification: VerificationProfile::default(), maximum_metadata_cache_seconds: DEFAULT_METADATA_CACHE_SECONDS, expected: ExpectedServiceProfile::default(), + oauth: None, origin_directory: None, }; profile( diff --git a/crates/registry-evidence-oid4vci/Cargo.toml b/crates/registry-evidence-oid4vci/Cargo.toml index 5a8d7e329c..ceeb2196cf 100644 --- a/crates/registry-evidence-oid4vci/Cargo.toml +++ b/crates/registry-evidence-oid4vci/Cargo.toml @@ -46,6 +46,6 @@ zeroize.workspace = true axum-test.workspace = true ed25519-dalek.workspace = true p256.workspace = true -registry-mint.workspace = true +registry-thunderid-tooling.workspace = true reqwest.workspace = true tempfile.workspace = true diff --git a/crates/registry-evidence-oid4vci/src/authorizer.rs b/crates/registry-evidence-oid4vci/src/authorizer.rs index 25c31cd4a2..8023cf636b 100644 --- a/crates/registry-evidence-oid4vci/src/authorizer.rs +++ b/crates/registry-evidence-oid4vci/src/authorizer.rs @@ -18,7 +18,8 @@ use std::{collections::HashSet, sync::Arc, time::Duration}; use async_trait::async_trait; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ - JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, + grant_claims, ClaimNames, JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, + TokenVerifierConfig, }; use crate::config::{AccessTokenAlgorithm, OfferAuthorizationConfig, ValidationMode}; @@ -65,6 +66,10 @@ pub trait OfferAuthorizer: Send + Sync { #[derive(Debug)] pub struct MintResourceServer { verifier: Arc, + /// Scopes every offer token must carry. Checked against the verified + /// token's scope set after verification, with the same closed refusal a + /// refused client gets, before any offer is stored or Evidence contacted. + required_scopes: Vec, } impl MintResourceServer { @@ -80,17 +85,30 @@ impl MintResourceServer { JwksFetcherConfig::defaults(), fetch_url_policy(config, mode), )); - Self::new(Arc::new(TokenVerifier::new( - verifier_profile(config), - fetcher, - ))) + let required_scopes = config.required_scopes.clone().unwrap_or_default(); + Self { + verifier: Arc::new(TokenVerifier::new(verifier_profile(config), fetcher)), + required_scopes, + } } /// Build the resource server over an already constructed verifier, for a - /// deployment that resolved its key source another way. + /// deployment that resolved its key source another way. No scope gate is + /// applied; the verifier alone decides. #[must_use] pub fn new(verifier: Arc) -> Self { - Self { verifier } + Self { + verifier, + required_scopes: Vec::new(), + } + } + + /// State scopes every offer token must carry, for a deployment that built + /// its verifier another way but still wants the scope gate. + #[must_use] + pub fn with_required_scopes(mut self, required_scopes: Vec) -> Self { + self.required_scopes = required_scopes; + self } } @@ -158,13 +176,43 @@ impl OfferAuthorizer for MintResourceServer { return Err(AuthorizationError::Missing); } match self.verifier.verify(credential).await { - Ok(verified) => Ok(AuthorizedOffer { - client: verified - .matched_client - .or_else(|| verified.claims.client_id.clone()) - .or_else(|| verified.claims.azp.clone()), - subject: verified.claims.sub.clone(), - }), + Ok(verified) => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| AuthorizationError::Refused)? + .as_secs(); + if !matches!( + grant_claims(&verified.claims, &ClaimNames::default(), now), + Ok(None) + ) { + // A wallet offer creates a deferred bearer lifecycle whose + // later redemption cannot recheck the task authority. No + // complete, partial, malformed, or expired task grant may + // cross this boundary. + return Err(AuthorizationError::Refused); + } + // The scope gate runs only on the verified token's scope set: + // a correctly signed token for this audience whose client + // holds static attributes but no offer scope grants nothing. + if !self.required_scopes.is_empty() { + let present: std::collections::HashSet<&str> = + verified.scopes.iter().map(String::as_str).collect(); + if !self + .required_scopes + .iter() + .all(|scope| present.contains(scope.as_str())) + { + return Err(AuthorizationError::Refused); + } + } + Ok(AuthorizedOffer { + client: verified + .matched_client + .or_else(|| verified.claims.client_id.clone()) + .or_else(|| verified.claims.azp.clone()), + subject: verified.claims.sub.clone(), + }) + } Err(error) if is_key_source_failure(&error) => { tracing::warn!( target: "registry_evidence_oid4vci::authorizer", diff --git a/crates/registry-evidence-oid4vci/src/config.rs b/crates/registry-evidence-oid4vci/src/config.rs index 64577222c6..f90d264c51 100644 --- a/crates/registry-evidence-oid4vci/src/config.rs +++ b/crates/registry-evidence-oid4vci/src/config.rs @@ -3,7 +3,7 @@ //! Everything here is fixed for the lifetime of the serving process: the //! published credential issuer identifier, the listener, the Evidence //! deployment this service requests credentials from, the identity it -//! authenticates to Mint with, and the bounds of the in-memory store. +//! authenticates to the configured token issuer with, and the bounds of the in-memory store. //! //! The document is read whole and validated whole, so a deployment either //! starts on a coherent configuration or does not start. `check` runs this same @@ -14,6 +14,10 @@ use std::{ path::{Path, PathBuf}, }; +use registry_platform_httputil::client::{ + valid_resource_uri, valid_scope_token, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, + MAXIMUM_SCOPE_PARAMETER_BYTES, +}; use serde::Deserialize; use thiserror::Error; use url::Url; @@ -166,7 +170,7 @@ pub struct EvidenceConfig { pub base_url: String, } -/// The identity this service authenticates to Mint with. +/// The identity this service authenticates to its token issuer with. /// /// This is the client half of the process. It signs a private key JWT client /// assertion with its own key and receives an access token Evidence accepts. @@ -175,7 +179,7 @@ pub struct EvidenceConfig { /// path. #[derive(Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct MintClientConfig { +pub struct TokenClientConfig { pub token_endpoint: String, pub client_id: String, /// The caller's own private JWK. Read owner-only when the outbound client @@ -185,15 +189,50 @@ pub struct MintClientConfig { /// to the token endpoint, which is the usual registration. #[serde(default)] pub client_assertion_audience: Option, + /// The fixed RFC 8707 resource identifier of the Evidence API. + #[serde(default)] + pub resource: Option, + /// The exact scopes requested from the issuer for Evidence access. + #[serde(default)] + pub scopes: Option>, } -impl MintClientConfig { +impl TokenClientConfig { #[must_use] pub fn client_assertion_audience(&self) -> &str { self.client_assertion_audience .as_deref() .unwrap_or(&self.token_endpoint) } + + fn validate_request(&self) -> Result<(), ConfigError> { + if let Some(resource) = self.resource.as_deref() { + if resource.len() > 2048 || !valid_resource_uri(resource) { + return Err(ConfigError::Invalid( + "tokenClient.resource must be an absolute URI without fragment or userinfo, at most 2048 bytes", + )); + } + } + if let Some(scopes) = self.scopes.as_ref() { + if scopes.is_empty() || scopes.len() > MAXIMUM_REQUESTED_SCOPES { + return Err(ConfigError::Invalid( + "tokenClient.scopes must contain 1..=32 scope tokens", + )); + } + let mut distinct = std::collections::HashSet::new(); + if scopes.iter().any(|scope| { + scope.len() > MAXIMUM_REQUESTED_SCOPE_BYTES + || !valid_scope_token(scope) + || !distinct.insert(scope) + }) || scopes.join(" ").len() > MAXIMUM_SCOPE_PARAMETER_BYTES + { + return Err(ConfigError::Invalid( + "tokenClient.scopes must be distinct bounded RFC 6749 scope tokens", + )); + } + } + Ok(()) + } } /// The signature algorithms an offer token may be signed with. @@ -219,8 +258,8 @@ fn default_maximum_token_lifetime_seconds() -> u64 { /// The authorization boundary of the adopter-facing offer endpoint. /// /// This is the resource-server half of the process, and it is deliberately a -/// separate document from [`MintClientConfig`]: the identity this service -/// authenticates to Mint with has nothing to do with the identities it accepts +/// separate document from [`TokenClientConfig`]: the identity this service +/// authenticates to the token issuer with has nothing to do with the identities it accepts /// tokens from, and nothing here is derived from that one. #[derive(Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -239,6 +278,12 @@ pub struct OfferAuthorizationConfig { /// the issuer vouched for, which is the usual single-adopter deployment. #[serde(default)] pub authorized_clients: Vec, + /// Scopes every offer token must carry, checked against the verified + /// token's scope set. Absent (`None`) keeps the no-scope-gate behavior; + /// present must be a nonempty list of RFC 6749 scope-tokens, and + /// generated configurations state `oid4vci:offer`. + #[serde(default)] + pub required_scopes: Option>, #[serde(default = "default_maximum_token_lifetime_seconds")] pub maximum_token_lifetime_seconds: u64, } @@ -281,6 +326,42 @@ impl OfferAuthorizationConfig { "every authorized offer client must be 1..=128 bytes", )); } + // A present-but-empty required-scope list gates nothing and is almost + // certainly a mis-authored key, so like a present empty audience it is + // refused rather than silently permissive. + if let Some(scopes) = &self.required_scopes { + if scopes.is_empty() { + return Err(ConfigError::Invalid( + "offer requiredScopes, when stated, must list at least one scope", + )); + } + if scopes + .iter() + .any(|scope| scope.trim().is_empty() || scope.len() > 256) + { + return Err(ConfigError::Invalid( + "every required offer scope must be 1..=256 bytes", + )); + } + if scopes + .iter() + .any(|scope| !registry_platform_httputil::valid_scope_token(scope)) + { + return Err(ConfigError::Invalid( + "offer requiredScopes must be RFC 6749 scope-tokens", + )); + } + if scopes + .iter() + .collect::>() + .len() + != scopes.len() + { + return Err(ConfigError::Invalid( + "offer requiredScopes must not repeat a scope", + )); + } + } if !(60..=3_600).contains(&self.maximum_token_lifetime_seconds) { return Err(ConfigError::Invalid( "the offer token lifetime ceiling must be 60..=3600 seconds", @@ -416,7 +497,7 @@ pub struct DeliveryConfig { #[serde(default)] pub metrics_listener: Option, pub evidence: EvidenceConfig, - pub mint: MintClientConfig, + pub token_client: TokenClientConfig, pub offers: OfferAuthorizationConfig, #[serde(default)] pub store: StoreConfig, @@ -427,6 +508,13 @@ impl DeliveryConfig { /// relative to its directory. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|_| ConfigError::Unavailable)?; + let keys: serde_norway::Value = serde_norway::from_str(&text) + .map_err(|error| ConfigError::Document(error.to_string()))?; + if keys.get("mint").is_some() { + return Err(ConfigError::Invalid( + "the mint configuration key is retired; use tokenClient", + )); + } let mut config: Self = serde_norway::from_str(&text) .map_err(|error| ConfigError::Document(error.to_string()))?; let root = path @@ -452,10 +540,10 @@ impl DeliveryConfig { } fn resolve_paths(&mut self, root: &Path) { - if !self.mint.private_key_file.as_os_str().is_empty() - && self.mint.private_key_file.is_relative() + if !self.token_client.private_key_file.as_os_str().is_empty() + && self.token_client.private_key_file.is_relative() { - self.mint.private_key_file = root.join(&self.mint.private_key_file); + self.token_client.private_key_file = root.join(&self.token_client.private_key_file); } } @@ -469,9 +557,9 @@ impl DeliveryConfig { ValidationMode::Strict => { validate_https_credential_issuer(&self.credential_issuer)?; validate_https_origin(&self.evidence.base_url, "the Evidence base URL")?; - validate_https_origin(&self.mint.token_endpoint, "the Mint token endpoint")?; + validate_https_origin(&self.token_client.token_endpoint, "the token endpoint")?; validate_https_origin( - self.mint.client_assertion_audience(), + self.token_client.client_assertion_audience(), "the client assertion audience", )?; validate_https_origin(&self.offers.issuer, "the offer token issuer")?; @@ -487,14 +575,16 @@ impl DeliveryConfig { metrics.validate(&self.listener)?; } - if self.mint.client_id.trim().is_empty() || self.mint.client_id.len() > 128 { + if self.token_client.client_id.trim().is_empty() || self.token_client.client_id.len() > 128 + { return Err(ConfigError::Invalid( - "the Mint client identifier must be 1..=128 bytes", + "the token client identifier must be 1..=128 bytes", )); } - if self.mint.private_key_file.as_os_str().is_empty() { - return Err(ConfigError::Invalid("a Mint client key file is required")); + if self.token_client.private_key_file.as_os_str().is_empty() { + return Err(ConfigError::Invalid("a token client key file is required")); } + self.token_client.validate_request()?; self.offers.validate()?; self.store.validate()?; Ok(()) @@ -505,7 +595,7 @@ impl DeliveryConfig { /// The credential issuer is published to wallets and compared byte for byte /// by a wallet proof's `aud`, so a supervised deployment has to serve /// exactly the origin it publishes. Every other origin this mode reaches is - /// loopback too: a supervised group that called a real Evidence or Mint + /// loopback too: a supervised group that called a real Evidence or token issuer /// deployment over plain HTTP would be a production deployment wearing a /// development label. fn validate_supervised_local_development_transport(&self) -> Result<(), ConfigError> { @@ -521,9 +611,12 @@ impl DeliveryConfig { } for (endpoint, subject) in [ (self.evidence.base_url.as_str(), "the Evidence base URL"), - (self.mint.token_endpoint.as_str(), "the Mint token endpoint"), ( - self.mint.client_assertion_audience(), + self.token_client.token_endpoint.as_str(), + "the token endpoint", + ), + ( + self.token_client.client_assertion_audience(), "the client assertion audience", ), (self.offers.issuer.as_str(), "the offer token issuer"), @@ -553,9 +646,7 @@ fn validate_https_origin(value: &str, subject: &'static str) -> Result<(), Confi "the Evidence base URL" => { ConfigError::Invalid("the Evidence base URL must be an absolute URL") } - "the Mint token endpoint" => { - ConfigError::Invalid("the Mint token endpoint must be an absolute URL") - } + "the token endpoint" => ConfigError::Invalid("the token endpoint must be an absolute URL"), "the offer token issuer" => { ConfigError::Invalid("the offer token issuer must be an absolute URL") } @@ -659,7 +750,7 @@ credentialIssuer: https://wallet.example.org listener: {address: 127.0.0.1, port: 8090} evidence: baseUrl: https://evidence.example.org -mint: +tokenClient: tokenEndpoint: https://mint.example.org/token clientId: evidence-oid4vci privateKeyFile: keys/delivery-client.jwk.json @@ -697,7 +788,7 @@ store: assert_eq!(config.credential_issuer, "https://wallet.example.org"); assert_eq!(config.listener.port, 8090); assert_eq!(config.evidence.base_url, "https://evidence.example.org"); - assert_eq!(config.mint.client_id, "evidence-oid4vci"); + assert_eq!(config.token_client.client_id, "evidence-oid4vci"); assert_eq!(config.store.maximum_transaction_code_attempts, 3); assert_eq!(config.validation_mode, ValidationMode::Strict); } @@ -716,7 +807,7 @@ store: fn the_client_assertion_audience_defaults_to_the_token_endpoint() { let config = load_from(VALID).expect("the configuration loads"); assert_eq!( - config.mint.client_assertion_audience(), + config.token_client.client_assertion_audience(), "https://mint.example.org/token" ); @@ -726,11 +817,63 @@ store: ); let config = load_from(&text).expect("the configuration loads"); assert_eq!( - config.mint.client_assertion_audience(), + config.token_client.client_assertion_audience(), "https://mint.example.org/other" ); } + #[test] + fn the_token_client_accepts_fixed_evidence_resource_and_scopes() { + let text = VALID.replace( + " clientId: evidence-oid4vci", + " clientId: evidence-oid4vci\n resource: urn:registry:evidence\n scopes: [evidence:invoke, evidence:discover]", + ); + let config = load_from(&text).expect("the token client configuration loads"); + assert_eq!( + config.token_client.resource.as_deref(), + Some("urn:registry:evidence") + ); + assert_eq!( + config.token_client.scopes.as_deref(), + Some(["evidence:invoke".to_owned(), "evidence:discover".to_owned()].as_slice()) + ); + } + + #[test] + fn the_token_client_refuses_unsafe_resource_and_scope_configuration() { + for extra in [ + "resource: /relative", + "resource: https://user@evidence.example.org", + "resource: https://evidence.example.org/#fragment", + "resource: https://evidence.example.org/a b", + "resource: https://evidence.example.org/é", + "resource: https://evidence.example.org/%GG", + "scopes: []", + "scopes: [evidence:invoke, evidence:invoke]", + "scopes: [\"not a scope\"]", + ] { + let text = VALID.replace( + " clientId: evidence-oid4vci", + &format!(" clientId: evidence-oid4vci\n {extra}"), + ); + assert!( + matches!(load_from(&text), Err(ConfigError::Invalid(_))), + "token client accepted invalid {extra}" + ); + } + } + + #[test] + fn the_old_mint_key_has_an_actionable_migration_error() { + let text = VALID.replace("tokenClient:", "mint:"); + assert_eq!( + load_from(&text), + Err(ConfigError::Invalid( + "the mint configuration key is retired; use tokenClient" + )) + ); + } + #[test] fn an_unknown_key_is_refused() { let text = VALID.replace("version: 1", "version: 1\nunexpected: true"); @@ -776,7 +919,7 @@ store: assert_eq!(config.offers.maximum_token_lifetime_seconds, 900); // Nothing in the offer boundary is derived from the Mint client // identity this service authenticates with. - assert_ne!(config.offers.issuer, config.mint.token_endpoint); + assert_ne!(config.offers.issuer, config.token_client.token_endpoint); } #[test] @@ -798,9 +941,35 @@ store: ), ( " authorizedClients: [adopter-front-end]", - " authorizedClients: [\"\"]", + r#" authorizedClients: [""]"#, + ), + // A stated requiredScopes list must be nonempty, scope-tokens, + // and free of repetition. (An absent or empty authorizedClients + // list stays legal: it accepts any issuer-vouched client.) + ( + " authorizedClients: [adopter-front-end]", + " requiredScopes: []", + ), + ( + " authorizedClients: [adopter-front-end]", + " requiredScopes: [oid4vci:offer, oid4vci:offer]", + ), + ( + " authorizedClients: [adopter-front-end]", + r#" requiredScopes: ["not a scope"]"#, ), ] { + // The positive control beside the refusals: a stated, well-formed + // requiredScopes list is itself accepted. + let stated = VALID.replace( + " authorizedClients: [adopter-front-end]", + " authorizedClients: [adopter-front-end]\n requiredScopes: [oid4vci:offer]", + ); + assert!( + load_from(&stated).is_ok(), + "a stated, well-formed requiredScopes list was refused" + ); + let text = VALID.replace(from, to); assert!( matches!(load_from(&text), Err(ConfigError::Invalid(_))), @@ -1119,7 +1288,7 @@ store: fs::write(&path, VALID).expect("configuration is written"); let config = DeliveryConfig::load(&path).expect("the configuration loads"); assert_eq!( - config.mint.private_key_file, + config.token_client.private_key_file, root.path().join("keys/delivery-client.jwk.json") ); } diff --git a/crates/registry-evidence-oid4vci/src/issuer.rs b/crates/registry-evidence-oid4vci/src/issuer.rs index a29b98ed62..a8d725568d 100644 --- a/crates/registry-evidence-oid4vci/src/issuer.rs +++ b/crates/registry-evidence-oid4vci/src/issuer.rs @@ -1,6 +1,6 @@ //! The client half of the process: asking Evidence for credentials. //! -//! This service signs nothing. It authenticates to Mint with its own private +//! This service signs nothing. It authenticates to its token issuer with its own private //! key JWT, presents the resulting access token to Evidence, and hands back //! whatever Evidence signed, unchanged. There is no credential signing key //! here, no holder private key, and no place to put either: the only key this @@ -108,18 +108,24 @@ impl EvidenceIssuer { /// The key is used to build the token provider and is not retained here in /// any other form. pub fn new(config: &DeliveryConfig, client_key: &str) -> Result { - let token_endpoint = Url::parse(&config.mint.token_endpoint) - .map_err(|_| IssuanceError::Configuration("the Mint token endpoint is not a URL"))?; + let token_endpoint = Url::parse(&config.token_client.token_endpoint) + .map_err(|_| IssuanceError::Configuration("the token endpoint is not a URL"))?; let base_url = Url::parse(&config.evidence.base_url) .map_err(|_| IssuanceError::Configuration("the Evidence base URL is not a URL"))?; let key = PrivateJwk::parse(client_key).map_err(|_| { - IssuanceError::Configuration("the Mint client key is not a private JWK") + IssuanceError::Configuration("the token client key is not a private JWK") })?; - let provider = PrivateKeyJwt::new( - PrivateKeyJwtConfig::new(token_endpoint, config.mint.client_id.clone(), key) - .with_audience(config.mint.client_assertion_audience().to_owned()), - ) - .map_err(|_| IssuanceError::Configuration("the Mint client identity is unusable"))?; + let mut provider_config = + PrivateKeyJwtConfig::new(token_endpoint, config.token_client.client_id.clone(), key) + .with_audience(config.token_client.client_assertion_audience().to_owned()); + if let Some(resource) = config.token_client.resource.as_deref() { + provider_config = provider_config.with_resource(resource.to_owned()); + } + if let Some(scopes) = config.token_client.scopes.as_ref() { + provider_config = provider_config.with_scopes(scopes.clone()); + } + let provider = PrivateKeyJwt::new(provider_config) + .map_err(|_| IssuanceError::Configuration("the token client identity is unusable"))?; let provider: Arc = Arc::new(provider); let client = NonVerifyingEvidenceClient::new(EvidenceClientConfig::without_verification( base_url, provider, @@ -161,6 +167,14 @@ impl CredentialIssuer for EvidenceIssuer { mod tests { use super::*; + use axum::{ + extract::{Form, State}, + routing::{get, post}, + Json, Router, + }; + use serde_json::json; + use std::collections::HashMap; + use crate::{config::tests::valid_config, testing::private_jwk}; #[test] @@ -176,11 +190,67 @@ mod tests { assert!(matches!( EvidenceIssuer::new(&config, "{}"), Err(IssuanceError::Configuration( - "the Mint client key is not a private JWK" + "the token client key is not a private JWK" )) )); } + #[tokio::test] + async fn the_outbound_client_requests_its_configured_resource_and_scopes() { + type Forms = Arc>>>; + + async fn token( + State(forms): State, + Form(form): Form>, + ) -> Json { + forms.lock().await.push(form); + Json(json!({ + "access_token": "synthetic-access-token", + "token_type": "Bearer", + "expires_in": 300, + })) + } + + let forms: Forms = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/oauth2/token", post(token)) + .route( + "/v1/evidence-definitions", + get(|| async { axum::http::StatusCode::FORBIDDEN }), + ) + .with_state(forms.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("a local issuer can bind"); + let origin = format!("http://{}", listener.local_addr().expect("bound address")); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("local issuer serves"); + }); + + let mut config = valid_config(); + config.evidence.base_url = origin.clone(); + config.token_client.token_endpoint = format!("{origin}/oauth2/token"); + config.token_client.client_assertion_audience = Some(origin); + config.token_client.resource = Some("urn:registry:evidence".to_owned()); + config.token_client.scopes = Some(vec!["evidence:invoke".to_owned()]); + let issuer = EvidenceIssuer::new(&config, &private_jwk("delivery-client")) + .expect("the outbound client is built"); + + let _ = issuer.catalog().await; + let recorded = forms.lock().await; + assert_eq!(recorded.len(), 1, "one token request precedes discovery"); + let form = &recorded[0]; + assert_eq!( + form.get("resource"), + Some(&"urn:registry:evidence".to_owned()) + ); + assert_eq!(form.get("scope"), Some(&"evidence:invoke".to_owned())); + assert_eq!(form.get("client_id"), Some(&"evidence-oid4vci".to_owned())); + server.abort(); + } + #[test] fn a_deployment_failure_is_never_reported_as_a_subject_statement() { // Every mapped failure is a coarse category. None of them can be read diff --git a/crates/registry-evidence-oid4vci/src/service.rs b/crates/registry-evidence-oid4vci/src/service.rs index 97b6d897d1..32191c437e 100644 --- a/crates/registry-evidence-oid4vci/src/service.rs +++ b/crates/registry-evidence-oid4vci/src/service.rs @@ -141,7 +141,7 @@ const PROOF_MAX_FUTURE_SKEW: Duration = Duration::from_secs(60); #[derive(Debug, Error)] pub enum ServiceError { - #[error("the Mint client key cannot be used: {0}")] + #[error("the token client key cannot be used: {0}")] ClientKey(#[from] SecretFileError), #[error("the outbound credential client cannot be built: {0}")] Issuer(#[from] IssuanceError), @@ -178,7 +178,7 @@ impl DeliveryService { /// with it, and dropped. Nothing else in the process keeps a copy, and the /// offer boundary is built from its own document without seeing it. pub fn load(config: DeliveryConfig) -> Result { - let client_key = read_owner_only(&config.mint.private_key_file)?; + let client_key = read_owner_only(&config.token_client.private_key_file)?; let authorizer = Arc::new(MintResourceServer::from_config( &config.offers, config.validation_mode, @@ -212,7 +212,7 @@ impl DeliveryService { /// Everything [`DeliveryService::load`] does, and no socket. The loaded key /// is dropped, and zeroized, before this returns. pub fn check(config: &DeliveryConfig) -> Result<(), ServiceError> { - let client_key = read_owner_only(&config.mint.private_key_file)?; + let client_key = read_owner_only(&config.token_client.private_key_file)?; EvidenceIssuer::new(config, &client_key)?; Ok(()) } @@ -1300,7 +1300,7 @@ mod tests { credentialIssuer: https://wallet.example.org\n\ listener:\n address: 127.0.0.1\n port: {port}\n\ evidence:\n baseUrl: https://evidence.example.org\n\ - mint:\n tokenEndpoint: https://mint.example.org/token\n clientId: evidence-oid4vci\n privateKeyFile: delivery-client.jwk.json\n\ + tokenClient:\n tokenEndpoint: https://mint.example.org/token\n clientId: evidence-oid4vci\n privateKeyFile: delivery-client.jwk.json\n\ offers:\n issuer: https://mint.example.org\n jwksUri: https://mint.example.org/.well-known/jwks.json\n audiences: [\"https://wallet.example.org\"]\n" ); fs::write(&path, text).expect("write the configuration document"); diff --git a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs index 4b6248e2d0..ada9c3504e 100644 --- a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs +++ b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs @@ -318,7 +318,7 @@ listener: port: 8090 evidence: baseUrl: https://evidence.example.org -mint: +tokenClient: tokenEndpoint: https://mint.example.org/token clientId: evidence-oid4vci privateKeyFile: unused-in-wired-test.jwk @@ -676,7 +676,7 @@ metricsListener: port: 18441 evidence: baseUrl: {SUPPORT_ORIGIN} -mint: +tokenClient: tokenEndpoint: {SUPPORT_ORIGIN}/token clientId: evidence-oid4vci-tutorial privateKeyFile: delivery-client.jwk.json diff --git a/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs b/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs index e790a8f903..ff587683c9 100644 --- a/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs +++ b/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs @@ -1,41 +1,42 @@ -//! End-to-end proof that the offer boundary is a resource server for tokens -//! Mint really issued. +//! Proof that the offer boundary is a resource server for standards-based +//! access tokens, including one issued by the pinned stock local issuer. //! //! The adopter-facing offer endpoint is the only authorization boundary this -//! service has, so its verification profile is not something to assert against -//! a token this test wrote itself. The deployment below is a real Mint on disk, -//! driven through its real router, and the token handed to the resource server -//! is the one Mint minted. Only the key source is substituted, for the same -//! reason Mint's own compatibility test substitutes it: the key set is the one -//! Mint published, read directly rather than over a network fetch. +//! service has. Focused cases use a deterministic signer to vary one claim at a +//! time. The ignored exact gate provisions the installed pinned issuer, obtains +//! a token with `private_key_jwt`, and verifies it against the issuer's own +//! public key snapshot. //! -//! Nothing here touches the client half of the process. This service's own Mint +//! Nothing here touches the client half of the process. This service's own token //! client identity has no part in any decision below, which is the separation //! [`registry_evidence_oid4vci::authorizer`] exists to keep. -use std::{fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; +use std::{ + collections::BTreeMap, + net::TcpListener, + os::unix::fs::PermissionsExt as _, + path::{Path, PathBuf}, + sync::Arc, +}; -use axum_test::TestServer; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_evidence_client::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; use registry_evidence_oid4vci::{ authorizer::{verifier_profile, AuthorizationError, MintResourceServer, OfferAuthorizer}, config::{AccessTokenAlgorithm, OfferAuthorizationConfig}, }; -use registry_mint::{ - config::MintConfig, - server::{build_app, MintService}, - CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, -}; use registry_platform_crypto::{sign, PrivateJwk, PublicJwk}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier}; +use registry_thunderid_tooling::{ + container::Session, + description::SessionIdentity, + local::{self, TypedLocalClient}, + render, + version::ThunderIdPin, +}; use serde_json::{json, Value}; -/// A fixed, non-secret audit HMAC key. Held as a byte literal rather than -/// written inline so a secret scanner does not read the write call as an -/// assignment of a live credential. -const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; -const ISSUER: &str = "http://127.0.0.1:18091"; -const ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18091/token"; +const FIXTURE_ISSUER: &str = "https://issuer.example.org"; const OFFER_AUDIENCE: &str = "https://delivery.example.org"; const CLIENT_ID: &str = "offer-caller"; const PRINCIPAL: &str = "urn:example:offer-caller"; @@ -76,169 +77,10 @@ fn signing_key_pair(seed: u8) -> (Value, Value) { ) } -struct Deployment { - /// Held so the directory outlives the service that reads from it. - _directory: tempfile::TempDir, - service: Arc, -} - -/// Write a complete Mint deployment to disk and load it exactly as the binary -/// would, including the owner-only permission requirement on the signing key. -async fn deployment() -> Deployment { - let directory = tempfile::tempdir().expect("a temporary directory"); - let root = directory.path(); - for child in ["secrets", "clients", "public-keys"] { - fs::create_dir(root.join(child)).expect("create a deployment directory"); - } - - let (signing_public, signing_document) = signing_key_pair(7); - let public_file = format!( - "{}.jwk.json", - signing_public["kid"].as_str().expect("the key has an id") - ); - fs::write( - root.join("public-keys").join(&public_file), - signing_public.to_string(), - ) - .expect("write the published public key"); - write_owner_only( - &root.join("secrets/signing.jwk"), - signing_document.to_string().as_bytes(), - ); - write_owner_only(&root.join("secrets/audit-hmac-key"), AUDIT_HASH_KEY); - - let (_, client_public) = client_key_pair(3); - fs::write( - root.join(format!("clients/{CLIENT_ID}.yaml")), - format!( - "clientId: {CLIENT_ID}\nprincipal: {PRINCIPAL}\nevidenceAudience: {OFFER_AUDIENCE}\nrequesterTags: [{CLIENT_ID}]\nkeys: [{client_public}]\n" - ), - ) - .expect("write the client registration"); - - let config_path = root.join("mint.yaml"); - fs::write( - &config_path, - format!( - r#" -version: 1 -validationMode: supervised-local-development -issuer: {ISSUER} -listener: {{address: 127.0.0.1, port: 18091}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{OFFER_AUDIENCE}] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -clientAssertion: - audience: {ASSERTION_AUDIENCE} - algorithms: [EdDSA] -clients: - directory: clients -"#, - root.join("secrets").display() - ), - ) - .expect("write the deployment configuration"); - - let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); - let service = Arc::new( - MintService::load(config) - .await - .expect("the deployment loads"), - ); - Deployment { - _directory: directory, - service, - } -} - -fn write_owner_only(path: &Path, contents: &[u8]) { - fs::write(path, contents).expect("write a deployment secret"); - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .expect("restrict a deployment secret"); -} - -fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { - let kid = private.kid.clone().expect("the test key has an id"); - let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); - let encode = |value: &Value| { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) - }; - let signing_input = format!("{}.{}", encode(&header), encode(claims)); - let signature = sign(signing_input.as_bytes(), private).expect("the key signs"); - format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - -/// Drive the real Mint router and return the token it issued together with the -/// key set it published. -async fn minted_offer_token() -> (String, Value) { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let published = http.get("/.well-known/jwks.json").await; - published.assert_status_ok(); - let key_set = published.json::(); - - let (private, _) = client_key_pair(3); - let now = chrono::Utc::now().timestamp(); - let assertion = sign_assertion( - &private, - &json!({ - "iss": CLIENT_ID, - "sub": CLIENT_ID, - "aud": ASSERTION_AUDIENCE, - "iat": now, - "exp": now + 120, - "jti": "offer-authorization-1", - }), - ); - let response = http - .post("/token") - .form(&vec![ - ( - "grant_type".to_owned(), - GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), - ), - ( - "client_assertion_type".to_owned(), - CLIENT_ASSERTION_TYPE.to_owned(), - ), - ("client_assertion".to_owned(), assertion), - ]) - .await; - response.assert_status_ok(); - let token = response.json::()["access_token"] - .as_str() - .expect("the response carries an access token") - .to_owned(); - (token, key_set) -} - -/// The offer boundary, built over the profile the deployment configuration -/// states and over the key set Mint published. +/// The offer boundary, built over the profile and a trusted key snapshot. fn resource_server(config: &OfferAuthorizationConfig, key_set: &Value) -> MintResourceServer { let parsed: jsonwebtoken::jwk::JwkSet = - serde_json::from_value(key_set.clone()).expect("Mint publishes a parsable key set"); + serde_json::from_value(key_set.clone()).expect("the issuer publishes a parsable key set"); let fetcher = Arc::new(JwksFetcher::new_static( parsed, JwksFetcherConfig::defaults(), @@ -247,41 +89,148 @@ fn resource_server(config: &OfferAuthorizationConfig, key_set: &Value) -> MintRe verifier_profile(config), fetcher, ))) + .with_required_scopes(config.required_scopes.clone().unwrap_or_default()) } -fn offer_config(audience: &str) -> OfferAuthorizationConfig { +fn offer_config(issuer: &str, audience: &str, algorithm: AccessTokenAlgorithm) -> OfferAuthorizationConfig { OfferAuthorizationConfig { - issuer: ISSUER.to_owned(), - jwks_uri: format!("{ISSUER}/.well-known/jwks.json"), + issuer: issuer.to_owned(), + jwks_uri: format!("{issuer}/oauth2/jwks"), audiences: vec![audience.to_owned()], - algorithms: vec![AccessTokenAlgorithm::ES256], + algorithms: vec![algorithm], authorized_clients: Vec::new(), + required_scopes: None, maximum_token_lifetime_seconds: 900, } } +/// The required-scope gate: a correctly signed, audience-matching token whose +/// scope set omits the configured offer permission authorizes nothing. #[tokio::test] -async fn a_token_mint_issued_authorizes_an_offer() { - let (token, key_set) = minted_offer_token().await; - let authorized = resource_server(&offer_config(OFFER_AUDIENCE), &key_set) - .authorize(&token) +async fn a_token_without_the_required_offer_scope_is_refused() { + let (issued, key_set) = signed_offer_fixture(json!({"scope":"offers:read"})); + let mut config = offer_config(FIXTURE_ISSUER, OFFER_AUDIENCE, AccessTokenAlgorithm::ES256); + config.required_scopes = Some(vec!["oid4vci:offer".to_owned()]); + assert_eq!( + resource_server(&config, &key_set).authorize(&issued).await, + Err(AuthorizationError::Refused), + "a correctly signed audience-matching token with no offer scope authorized an offer" + ); + + let (signing_public, signing_document) = signing_key_pair(7); + let private = + PrivateJwk::parse(&signing_document.to_string()).expect("the fixture signing key parses"); + let kid = signing_public["kid"].as_str().expect("the key has an id"); + let now = chrono::Utc::now().timestamp(); + let header = json!({"alg": "ES256", "typ": "at+jwt", "kid": kid}); + let claims = json!({ + "iss": FIXTURE_ISSUER, + "aud": OFFER_AUDIENCE, + "sub": PRINCIPAL, + "iat": now, + "exp": now + 300, + "scope": "oid4vci:offer offers:read", + }); + let encode = + |value: &Value| URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("serializes")); + let signing_input = format!("{}.{}", encode(&header), encode(&claims)); + let signature = sign(signing_input.as_bytes(), &private).expect("the fixture signs"); + let fixture = format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)); + + let key_set = json!({"keys": [signing_public]}); + resource_server(&config, &key_set) + .authorize(&fixture) .await - .expect("the offer boundary accepts a token Mint issued"); + .expect("a token carrying the required offer scope authorizes an offer"); +} - // Both come from the server-side registration Mint holds, never from - // anything the caller asserted. - assert_eq!(authorized.client.as_deref(), Some(CLIENT_ID)); - assert_eq!(authorized.subject.as_deref(), Some(PRINCIPAL)); +fn signed_offer_fixture(extra: Value) -> (String, Value) { + let (signing_public, signing_document) = signing_key_pair(7); + let private = + PrivateJwk::parse(&signing_document.to_string()).expect("the fixture signing key parses"); + let kid = signing_public["kid"].as_str().expect("the key has an id"); + let now = chrono::Utc::now().timestamp(); + let header = json!({"alg": "ES256", "typ": "at+jwt", "kid": kid}); + let mut claims = json!({ + "iss": FIXTURE_ISSUER, + "aud": OFFER_AUDIENCE, + "sub": PRINCIPAL, + "iat": now, + "exp": now + 300, + "scope": "oid4vci:offer", + }); + claims + .as_object_mut() + .expect("claims are an object") + .extend( + extra + .as_object() + .expect("extra claims are an object") + .clone(), + ); + let encode = + |value: &Value| URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("serializes")); + let signing_input = format!("{}.{}", encode(&header), encode(&claims)); + let signature = sign(signing_input.as_bytes(), &private).expect("the fixture signs"); + ( + format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)), + json!({"keys": [signing_public]}), + ) } #[tokio::test] -async fn a_token_mint_issued_for_another_resource_server_is_refused() { - let (token, key_set) = minted_offer_token().await; +async fn task_bound_and_partial_grants_cannot_create_deferred_wallet_offers() { + let now = chrono::Utc::now().timestamp(); + let (task_bound, keys) = signed_offer_fixture(json!({ + "registry_actor_kind":"agent", + "registry_grant_id":"grant-a", + "registry_grant_authority":"authority-a", + "registry_grant_source_issuer":"https://casework.example", + "registry_grant_client":"offer-caller", + "registry_grant_resource":OFFER_AUDIENCE, + "registry_purpose":"credential-delivery", + "registry_grant_exp":now + 300, + "registry_grant_bounds":{"type":"evidence","requirement":"urn:example:requirement"} + })); + let config = { + let mut config = offer_config(FIXTURE_ISSUER, OFFER_AUDIENCE, AccessTokenAlgorithm::ES256); + config.required_scopes = Some(vec!["oid4vci:offer".to_owned()]); + config + }; + assert_eq!( + resource_server(&config, &keys).authorize(&task_bound).await, + Err(AuthorizationError::Refused) + ); + + let (partial, keys) = signed_offer_fixture(json!({"registry_grant_id":"grant-a"})); + assert_eq!( + resource_server(&config, &keys).authorize(&partial).await, + Err(AuthorizationError::Refused) + ); +} + +#[tokio::test] +async fn an_ordinary_service_offer_with_purpose_but_no_grant_remains_supported() { + let (ordinary, keys) = signed_offer_fixture(json!({ + "registry_actor_kind":"service", + "registry_purpose":"credential-delivery" + })); + let mut config = offer_config(FIXTURE_ISSUER, OFFER_AUDIENCE, AccessTokenAlgorithm::ES256); + config.required_scopes = Some(vec!["oid4vci:offer".to_owned()]); + resource_server(&config, &keys) + .authorize(&ordinary) + .await + .expect("an ordinary service offer remains supported"); +} + +#[tokio::test] +async fn a_token_issued_for_another_resource_server_is_refused() { + let (token, key_set) = signed_offer_fixture(json!({})); // The same real token, presented to a deployment that answers to a // different audience. An adopter's token for another resource server is // not an offer authorization here. assert_eq!( - resource_server(&offer_config("https://elsewhere.example.org"), &key_set) + resource_server(&offer_config(FIXTURE_ISSUER, "https://elsewhere.example.org", AccessTokenAlgorithm::ES256), &key_set) .authorize(&token) .await, Err(AuthorizationError::Refused) @@ -290,8 +239,8 @@ async fn a_token_mint_issued_for_another_resource_server_is_refused() { #[tokio::test] async fn a_token_this_issuer_did_not_sign_is_refused() { - let (token, key_set) = minted_offer_token().await; - let config = offer_config(OFFER_AUDIENCE); + let (token, key_set) = signed_offer_fixture(json!({})); + let config = offer_config(FIXTURE_ISSUER, OFFER_AUDIENCE, AccessTokenAlgorithm::ES256); let server = resource_server(&config, &key_set); // A token whose signature was replaced, presented otherwise unchanged. @@ -308,3 +257,128 @@ async fn a_token_this_issuer_did_not_sign_is_refused() { ); assert_eq!(server.authorize("").await, Err(AuthorizationError::Missing)); } + +struct OwnedStockSession { + label: String, + id: String, + port: u16, + state_root: PathBuf, + image: String, + docker: PathBuf, +} + +impl Drop for OwnedStockSession { + fn drop(&mut self) { + let session = Session { + label: &self.label, + id: &self.id, + port: self.port, + state_root: &self.state_root, + image: &self.image, + }; + let _ = local::stop(&session, &self.docker); + } +} + +fn installed_or_env(variable: &str, binary: &str) -> PathBuf { + if let Some(path) = std::env::var_os(variable) { + return PathBuf::from(path); + } + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(binary)) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| panic!("set {variable} for the exact stock-issuer gate")) +} + +/// Replacement acceptance for the former in-process Mint fixture. This gate +/// uses the installed pinned issuer, its native private-key-JWT registration, +/// its real token endpoint, and the exact public RS256 keys it served. +#[test] +#[ignore = "exact gate: starts the pinned stock issuer container"] +fn stock_issuer_service_token_authorizes_the_offer_boundary() { + let reservation = TcpListener::bind("127.0.0.1:0").expect("reserve issuer port"); + let port = reservation.local_addr().expect("issuer address").port(); + let issuer = format!("http://127.0.0.1:{port}"); + let directory = tempfile::tempdir().expect("private stock issuer state"); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .expect("issuer state is owner-only"); + let state_root = directory.path().join("issuer"); + let label = format!("oid4vci-offer-{port}"); + let id = format!("oid4vci-offer-session-{port}"); + let (public, private) = signing_key_pair(11); + let description = local::typed_local_description( + SessionIdentity { + label: label.clone(), + id: id.clone(), + }, + port, + state_root.clone(), + OFFER_AUDIENCE.to_owned(), + vec![TypedLocalClient { + client_id: CLIENT_ID.to_owned(), + public_jwks: json!({"keys":[public]}).to_string(), + claims: BTreeMap::from([ + ("registry_actor_kind".to_owned(), json!("service")), + ("registry_purpose".to_owned(), json!("credential-delivery")), + ]), + scopes: vec!["oid4vci:offer".to_owned()], + allow_human_fixture: false, + }], + ) + .expect("the stock issuer description is valid"); + render::render(&description).expect("the stock issuer resources render"); + let pin = ThunderIdPin::load().expect("the maintained stock issuer pin loads"); + let docker = installed_or_env("DOCKER_BIN", "docker"); + let session = Session { + label: &label, + id: &id, + port, + state_root: &state_root, + image: &pin.image, + }; + drop(reservation); + let keys = local::start(&session, &docker, &mut || false) + .expect("the pinned stock issuer starts"); + let _owned = OwnedStockSession { + label, + id, + port, + state_root, + image: pin.image, + docker, + }; + + let private = PrivateJwk::parse(&private.to_string()).expect("the client key parses"); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{issuer}/oauth2/token") + .parse() + .expect("the token endpoint parses"), + CLIENT_ID, + private, + ) + .with_audience(issuer.clone()) + .with_resource(OFFER_AUDIENCE) + .with_scopes(["oid4vci:offer".to_owned()]), + ) + .expect("the private-key-JWT client is valid"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("the HTTP runtime builds"); + let issued = runtime + .block_on(provider.bearer_token()) + .expect("the stock issuer issues an offer token"); + let authorization = issued.authorization_header_value(); + let compact = authorization + .to_str() + .expect("the issued token is header-safe") + .strip_prefix("Bearer ") + .expect("the provider uses the bearer scheme"); + let config = offer_config(&issuer, OFFER_AUDIENCE, AccessTokenAlgorithm::RS256); + let authorized = runtime + .block_on(resource_server(&config, &keys).authorize(compact)) + .expect("the offer boundary accepts the stock issuer token"); + assert_eq!(authorized.client.as_deref(), Some(CLIENT_ID)); + assert!(authorized.subject.is_some()); +} diff --git a/crates/registry-evidence-oid4vci/tests/state_machine_recovery.rs b/crates/registry-evidence-oid4vci/tests/state_machine_recovery.rs index 5f9fd32022..ac4fd00633 100644 --- a/crates/registry-evidence-oid4vci/tests/state_machine_recovery.rs +++ b/crates/registry-evidence-oid4vci/tests/state_machine_recovery.rs @@ -45,7 +45,7 @@ credentialIssuer: https://wallet.example.org listener: {address: 127.0.0.1, port: 8090} evidence: baseUrl: https://evidence.example.org -mint: +tokenClient: tokenEndpoint: https://mint.example.org/token clientId: evidence-oid4vci privateKeyFile: unused-delivery-client.jwk.json diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 6d4b5de2cc..8500087cae 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -17,9 +17,10 @@ pub use registry_platform_audit::segmented_audit_paths as audit_segment_paths; use registry_platform_audit::{ verify_segmented_audit_chain, visit_stopped_segmented_audit_chain, AuditChainHasher, AuditEnvelope, AuditError, AuditHashSecret, AuditKeyHasher, AuditProfile, - DurableSegmentedAuditLog, + AuthorizationAuditEvent, AuthorizationOutcome, DurableSegmentedAuditLog, }; use registry_platform_crypto::canonicalize_json; +use registry_platform_oidc::ActorKind; use serde::{Deserialize, Serialize}; use thiserror::Error; use zeroize::Zeroizing; @@ -77,10 +78,16 @@ pub struct EvidenceAuthorizationRefusalAuditEvent { pub phase: AuditPhase, pub bundle_revision: String, pub requester_pseudonym: String, + pub actor_kind: ActorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub grant_pseudonym: Option, #[serde(skip_serializing_if = "Option::is_none")] pub actor_pseudonym: Option, pub decision: AuthorizationRefusalAuditDecision, pub safe_error_category: String, + pub reason: String, pub duration_milliseconds: u64, } @@ -101,9 +108,13 @@ impl EvidenceAuthorizationRefusalAuditEvent { phase: AuditPhase::Denial, bundle_revision, requester_pseudonym, + actor_kind: ActorKind::Service, + client_pseudonym: None, + grant_pseudonym: None, actor_pseudonym: None, decision: AuthorizationRefusalAuditDecision::NotAuthorized, safe_error_category: AUTHORIZATION_REFUSAL_ERROR_CATEGORY.to_owned(), + reason: "authorization.profile".to_owned(), duration_milliseconds, } } @@ -117,15 +128,35 @@ impl EvidenceAuthorizationRefusalAuditEvent { || !(16..=128).contains(&self.operation.len()) || !valid_revision(&self.bundle_revision) || !valid_pseudonym(&self.requester_pseudonym) + || self + .client_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) + || self + .grant_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) || self .actor_pseudonym .as_ref() .is_some_and(|value| !valid_pseudonym(value)) || self.safe_error_category != AUTHORIZATION_REFUSAL_ERROR_CATEGORY + || !valid_purpose(&self.reason, 128) || self.duration_milliseconds > 86_400_000 { return Err(EvidenceAuditError::InvalidEvent); } + if let Some(client) = &self.client_pseudonym { + AuthorizationAuditEvent::denied_without_purpose( + self.actor_kind.as_str(), + self.requester_pseudonym.clone(), + client.clone(), + self.grant_pseudonym.clone(), + self.operation.clone(), + self.reason.clone(), + ) + .map_err(|_| EvidenceAuditError::InvalidEvent)?; + } Ok(()) } } @@ -239,10 +270,14 @@ pub struct EvidenceRequestBatchAuditEvent { pub bundle_revision: String, pub purpose: String, pub requester_pseudonym: String, + pub actor_kind: ActorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_pseudonym: Option, #[serde(skip_serializing_if = "Option::is_none")] pub actor_pseudonym: Option, pub response_protection: ResponseProtection, pub decision: EvidenceRequestBatchAuditDecision, + pub reason: String, #[serde(skip_serializing_if = "Option::is_none")] pub source_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -286,9 +321,12 @@ impl EvidenceRequestBatchAuditEvent { bundle_revision, purpose, requester_pseudonym, + actor_kind: ActorKind::Service, + client_pseudonym: None, actor_pseudonym: None, response_protection: ResponseProtection::Signed, decision, + reason: "authorization.allowed".to_owned(), source_id: None, adapter_id: None, item_indices: None, @@ -310,11 +348,16 @@ impl EvidenceRequestBatchAuditEvent { && valid_revision(&self.bundle_revision) && valid_purpose(&self.purpose, 128) && valid_pseudonym(&self.requester_pseudonym) + && self + .client_pseudonym + .as_ref() + .is_none_or(|value| valid_pseudonym(value)) && self .actor_pseudonym .as_ref() .is_none_or(|value| valid_pseudonym(value)) && self.response_protection == ResponseProtection::Signed + && valid_purpose(&self.reason, 128) && self.duration_milliseconds <= 86_400_000; if !common_valid { return Err(EvidenceAuditError::InvalidEvent); @@ -494,6 +537,9 @@ pub struct EvidenceAuditEvent { pub bundle_revision: String, pub purpose: String, pub requester_pseudonym: String, + pub actor_kind: ActorKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_pseudonym: Option, #[serde(skip_serializing_if = "Option::is_none")] pub actor_pseudonym: Option, pub authority: AuditAuthority, @@ -515,6 +561,7 @@ pub struct EvidenceAuditEvent { #[serde(skip_serializing_if = "Option::is_none")] pub adapter_ids: Option>, pub decision: AuditDecision, + pub reason: String, #[serde(skip_serializing_if = "Option::is_none")] pub disclosed_concepts: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -565,6 +612,8 @@ impl EvidenceAuditEvent { bundle_revision, purpose, requester_pseudonym, + actor_kind: ActorKind::Service, + client_pseudonym: None, actor_pseudonym: None, authority, subjects, @@ -574,6 +623,7 @@ impl EvidenceAuditEvent { source_ids: None, adapter_ids: None, decision, + reason: "authorization.allowed".to_owned(), disclosed_concepts: None, evidence_id: None, evidence_ids: None, @@ -664,6 +714,10 @@ impl EvidenceAuditEvent { || !valid_revision(&self.bundle_revision) || !valid_purpose(&self.purpose, 128) || !valid_pseudonym(&self.requester_pseudonym) + || self + .client_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) || self .actor_pseudonym .as_ref() @@ -707,9 +761,23 @@ impl EvidenceAuditEvent { .as_ref() .is_some_and(|value| !valid_local_name(value, 128)) || self.duration_milliseconds > 86_400_000 + || !valid_purpose(&self.reason, 128) { return Err(EvidenceAuditError::InvalidEvent); } + if let Some(client) = &self.client_pseudonym { + AuthorizationAuditEvent::new( + self.actor_kind.as_str(), + self.requester_pseudonym.clone(), + client.clone(), + self.authority.grant_pseudonym.clone(), + self.purpose.clone(), + self.operation.clone(), + AuthorizationOutcome::Allowed, + self.reason.clone(), + ) + .map_err(|_| EvidenceAuditError::InvalidEvent)?; + } Ok(()) } } @@ -1225,7 +1293,10 @@ fn coherent_operation_pair(access: &EvidenceAuditEvent, terminal: &EvidenceAudit && access.bundle_revision == terminal.bundle_revision && access.purpose == terminal.purpose && access.requester_pseudonym == terminal.requester_pseudonym + && access.actor_kind == terminal.actor_kind + && access.client_pseudonym == terminal.client_pseudonym && access.actor_pseudonym == terminal.actor_pseudonym + && access.reason == terminal.reason && access.authority == terminal.authority && access.subjects == terminal.subjects && access.response_protection == terminal.response_protection @@ -1666,6 +1737,8 @@ mod tests { requester_pseudonym: "hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111" .to_owned(), + actor_kind: ActorKind::Service, + client_pseudonym: None, actor_pseudonym: None, authority: AuditAuthority { kind: AuthorityKind::Statutory, @@ -1685,6 +1758,7 @@ mod tests { source_ids: None, adapter_ids: None, decision: AuditDecision::Authorized, + reason: "authorization.allowed".to_owned(), disclosed_concepts: None, evidence_id: None, evidence_ids: None, @@ -1775,12 +1849,16 @@ mod tests { requester_pseudonym: "hmac-sha256:v1:3333333333333333333333333333333333333333333333333333333333333333" .to_owned(), + actor_kind: ActorKind::Service, + client_pseudonym: None, + grant_pseudonym: None, actor_pseudonym: Some( "hmac-sha256:v1:4444444444444444444444444444444444444444444444444444444444444444" .to_owned(), ), decision: AuthorizationRefusalAuditDecision::NotAuthorized, safe_error_category: "not-authorized".to_owned(), + reason: "authorization.profile".to_owned(), duration_milliseconds: 3, }; refusal @@ -2836,6 +2914,7 @@ mod tests { .keys() .collect::>(), [ + "actorKind", "actorPseudonym", "assuranceProfile", "bundleRevision", @@ -2845,6 +2924,7 @@ mod tests { "occurredAt", "operation", "phase", + "reason", "requesterPseudonym", "safeErrorCategory", "schema", diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index 3ffad3c7eb..a8ac58688e 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -2,7 +2,7 @@ use std::{ sync::{Arc, Mutex, MutexGuard, PoisonError}, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; #[cfg(test)] @@ -10,7 +10,9 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use registry_platform_authcommon::validate_compact_access_token; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ - JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, TokenVerifierConfig, VerifiedToken, + actor_kind, grant_claims, ActorKind, Audience, ClaimError, ClaimNames, GrantClaims, + GrantContextError, JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, + TokenVerifierConfig, VerifiedToken, }; use serde_json::{Map, Value}; use thiserror::Error; @@ -60,8 +62,7 @@ pub struct AuthenticationClaimsConfig { pub principal_claim: String, pub requester_tags_claim: String, pub evidence_audience_claim: String, - pub grant_id_claim: String, - pub grant_authority_claim: String, + pub contextual_claims: ClaimNames, pub actor_claim: Option, } @@ -69,6 +70,14 @@ pub struct AuthenticationClaimsConfig { pub struct Authenticator { verifier: Arc, claims: AuthenticationClaimsConfig, + /// Scopes every token must carry, checked after verification and before + /// any authority claim is read. Empty keeps the no-scope-gate behavior. + /// Client admission is not held here: it is configured on the platform + /// verifier, in its documented `client_id`/`azp` semantics. + required_scopes: Vec, + /// Resource identifiers accepted by the verifier. A task grant must name + /// the one member that actually matched the token audience. + resources: Vec, key_source: Arc>, } @@ -97,13 +106,27 @@ impl std::fmt::Debug for Authenticator { pub struct AuthenticatedContext { principal: String, actor: Option, + actor_kind: ActorKind, + client: Option, requester_tags: Vec, evidence_audience: String, - grant_id: Option, - grant_authority: Option, + task_grant: Result, TaskGrantError>, verified_claims: Value, } +/// A redacted reason a present task-grant context cannot be trusted. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum TaskGrantError { + #[error("task-grant claims are invalid")] + Claims, + #[error("task-grant principal does not match the authenticated principal")] + PrincipalMismatch, + #[error("task-grant client context is invalid")] + Client, + #[error("task-grant resource context is invalid")] + Resource, +} + impl AuthenticatedContext { pub fn principal(&self) -> &str { &self.principal @@ -113,6 +136,14 @@ impl AuthenticatedContext { self.actor.as_deref() } + pub const fn actor_kind(&self) -> ActorKind { + self.actor_kind + } + + pub fn client(&self) -> Option<&str> { + self.client.as_deref() + } + pub fn requester_tags(&self) -> &[String] { &self.requester_tags } @@ -122,11 +153,18 @@ impl AuthenticatedContext { } pub fn grant_id(&self) -> Option<&str> { - self.grant_id.as_deref() + self.grant().ok().flatten().map(GrantClaims::id) } pub fn grant_authority(&self) -> Option<&str> { - self.grant_authority.as_deref() + self.grant().ok().flatten().map(GrantClaims::authority) + } + + pub fn grant(&self) -> Result, TaskGrantError> { + self.task_grant + .as_ref() + .map(Option::as_ref) + .map_err(|error| *error) } pub fn claim_path(&self, path: &str) -> Option<&Value> { @@ -141,17 +179,19 @@ impl AuthenticatedContext { pub(crate) fn offline_fixture_context( requester_tags: Vec, evidence_audience: &str, - grant_id: Option<&str>, - grant_authority: Option<&str>, + actor_kind: ActorKind, + client: Option<&str>, + task_grant: Result, TaskGrantError>, verified_claims: Value, ) -> Self { Self { principal: "offline-fixture-principal".to_owned(), actor: None, + actor_kind, + client: client.map(ToOwned::to_owned), requester_tags, evidence_audience: evidence_audience.to_owned(), - grant_id: grant_id.map(ToOwned::to_owned), - grant_authority: grant_authority.map(ToOwned::to_owned), + task_grant, verified_claims, } } @@ -161,15 +201,15 @@ impl AuthenticatedContext { principal: &str, requester_tags: Vec, evidence_audience: &str, - grant_id: Option<&str>, - grant_authority: Option<&str>, + task_grant: Result, TaskGrantError>, verified_claims: Value, ) -> Self { let mut context = Self::offline_fixture_context( requester_tags, evidence_audience, - grant_id, - grant_authority, + ActorKind::Service, + None, + task_grant, verified_claims, ); context.principal = principal.to_owned(); @@ -183,12 +223,13 @@ impl std::fmt::Debug for AuthenticatedContext { .debug_struct("AuthenticatedContext") .field("principal", &"") .field("actor", &self.actor.as_ref().map(|_| "")) + .field("actor_kind", &self.actor_kind) + .field("client", &self.client.as_ref().map(|_| "")) .field("requester_tags", &"") .field("evidence_audience", &self.evidence_audience) - .field("grant_id", &self.grant_id.as_ref().map(|_| "")) .field( - "grant_authority", - &self.grant_authority.as_ref().map(|_| ""), + "task_grant", + &self.task_grant.as_ref().map(|_| ""), ) .field("verified_claims", &"") .finish() @@ -241,7 +282,8 @@ impl Authenticator { .with_denied_kids(config.revoked_key_ids.iter().cloned().collect()) .with_max_token_lifetime(Some(Duration::from_secs( config.maximum_token_lifetime_seconds, - ))); + ))) + .with_allowed_clients(config.allowed_clients.clone().unwrap_or_default()); let fetcher = Arc::new(JwksFetcher::new_with_fetch_url_policy( config.jwks_uri.clone(), JwksFetcherConfig::defaults(), @@ -252,21 +294,39 @@ impl Authenticator { principal_claim: config.principal_claim.clone(), requester_tags_claim: config.requester_tags_claim.clone(), evidence_audience_claim: config.evidence_audience_claim.clone(), - grant_id_claim: config.grant_id_claim.clone(), - grant_authority_claim: config.grant_authority_claim.clone(), + contextual_claims: config.claims.clone(), actor_claim: config.actor_claim.clone(), }; Self::new(verifier, claims) + .with_required_scopes(config.required_scopes.clone().unwrap_or_default()) + .with_resources(config.audiences.clone()) } pub fn new(verifier: Arc, claims: AuthenticationClaimsConfig) -> Self { Self { verifier, claims, + required_scopes: Vec::new(), + resources: Vec::new(), key_source: Arc::new(Mutex::new(KeySourceState::default())), } } + /// State the scopes every token must carry. An empty list keeps the + /// permissive default, which is the behavior a configuration that states + /// no `requiredScopes` means. + #[must_use] + pub fn with_required_scopes(mut self, required_scopes: Vec) -> Self { + self.required_scopes = required_scopes; + self + } + + #[must_use] + pub fn with_resources(mut self, resources: Vec) -> Self { + self.resources = resources; + self + } + pub async fn authenticate( &self, access_token: &str, @@ -281,6 +341,24 @@ impl Authenticator { return Err(AuthenticationError::Verification); } }; + // The scope gate reads only the verified token's scope set. It runs + // after signature verification and before any authority claim is + // read, so a token that lacks a required scope reaches no protected + // source operation and no authority interpretation. A missing scope + // is never inferred from tags, principal, roles, `sub`, or request + // fields, and the refusal is the same closed authentication failure a + // refused client admission gets. + if !self.required_scopes.is_empty() { + let present: std::collections::HashSet<&str> = + verified.scopes.iter().map(String::as_str).collect(); + if !self + .required_scopes + .iter() + .all(|scope| present.contains(scope.as_str())) + { + return Err(AuthenticationError::Verification); + } + } self.extract_context(verified) } @@ -427,8 +505,17 @@ impl Authenticator { &self, verified: VerifiedToken, ) -> Result { + let actor_kind = actor_kind(&verified.claims, &self.claims.contextual_claims) + .map_err(|_| AuthenticationError::Context)?; + let now_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AuthenticationError::Context)? + .as_secs(); + let mut task_grant = + grant_claims(&verified.claims, &self.claims.contextual_claims, now_unix) + .map_err(|_: ClaimError| TaskGrantError::Claims); let claims = - serde_json::to_value(verified.claims).map_err(|_| AuthenticationError::Context)?; + serde_json::to_value(&verified.claims).map_err(|_| AuthenticationError::Context)?; let claims_object = claims.as_object().ok_or(AuthenticationError::Context)?; // Version one validates no proof of possession. Treating a @@ -460,37 +547,74 @@ impl Authenticator { .map(|claim| optional_direct_string(claims_object, claim, MAX_PRINCIPAL_BYTES)) .transpose()? .flatten(); - let grant_id = optional_direct_string( - claims_object, - &self.claims.grant_id_claim, - MAX_PRINCIPAL_BYTES, - )?; - let grant_authority = optional_direct_string( - claims_object, - &self.claims.grant_authority_claim, - MAX_PRINCIPAL_BYTES, - )?; - if grant_id.is_some() != grant_authority.is_some() { - return Err(AuthenticationError::Context); + if let Ok(Some(grant)) = &task_grant { + if grant.principal() != principal { + task_grant = Err(TaskGrantError::PrincipalMismatch); + } else { + match exactly_matched_resource(verified.claims.aud.as_ref(), &self.resources) { + Some(resource) => { + if let Err(error) = grant.verify_context(&verified, resource) { + task_grant = Err(match error { + GrantContextError::MissingVerifiedClient + | GrantContextError::InvalidVerifiedClient + | GrantContextError::ClientMismatch => TaskGrantError::Client, + GrantContextError::ResourceMismatch => TaskGrantError::Resource, + _ => TaskGrantError::Claims, + }); + } + } + None => task_grant = Err(TaskGrantError::Resource), + } + } } + let client = verified + .matched_client_id() + .ok() + .flatten() + .or(verified.claims.azp.as_deref()) + .or(verified.claims.client_id.as_deref()) + .map(ToOwned::to_owned); Ok(AuthenticatedContext { principal, actor, + actor_kind, + client, requester_tags, evidence_audience, - grant_id, - grant_authority, + task_grant, verified_claims: claims, }) } } +fn exactly_matched_resource<'a>( + audience: Option<&'a Audience>, + resources: &[String], +) -> Option<&'a str> { + let matched = match audience? { + Audience::One(value) => resources + .contains(value) + .then_some(value.as_str()) + .into_iter() + .collect(), + Audience::Many(values) => values + .iter() + .filter(|value| resources.contains(*value)) + .map(String::as_str) + .collect::>(), + }; + match matched.as_slice() { + [only] => Some(*only), + _ => None, + } +} + fn jwks_fetch_policy( config: &AuthenticationConfig, assurance_profile: AssuranceProfile, ) -> FetchUrlPolicy { - if config.uses_local_mint_http(assurance_profile) { + if config.uses_local_issuer_http(assurance_profile) { return FetchUrlPolicy { allowed_schemes: vec!["http".to_owned()], allow_localhost: true, @@ -662,7 +786,7 @@ mod tests { } #[test] - fn jwks_fetch_policy_opens_http_only_for_exact_local_mint() { + fn jwks_fetch_policy_opens_http_only_for_the_supervised_local_issuer() { let mut exact = authentication_config(); exact.issuer = "http://127.0.0.1:8081".to_owned(); exact.jwks_uri = "http://127.0.0.1:8081/.well-known/jwks.json".to_owned(); @@ -671,6 +795,15 @@ mod tests { assert!(local.allow_localhost); assert!(local.deny_private_ranges); + // The JWKS path is the issuer's to choose; what stays fixed is the + // exact same numeric loopback origin. + let mut other_path = exact.clone(); + other_path.jwks_uri = "http://127.0.0.1:8081/oauth2/jwks".to_owned(); + assert_eq!( + jwks_fetch_policy(&other_path, AssuranceProfile::Local).allowed_schemes, + ["http"] + ); + for (profile, issuer, jwks_uri) in [ ( AssuranceProfile::Production, @@ -880,8 +1013,7 @@ mod tests { principal_claim: "sub".to_owned(), requester_tags_claim: "evidence_tags".to_owned(), evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), + contextual_claims: ClaimNames::default(), actor_claim: None, }, ); @@ -920,8 +1052,7 @@ mod tests { principal_claim: "sub".to_owned(), requester_tags_claim: "evidence_tags".to_owned(), evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), + contextual_claims: ClaimNames::default(), actor_claim: None, }, ); @@ -948,8 +1079,7 @@ mod tests { "principal-canary", vec!["tag-canary".to_string()], "urn:example:audience", - Some("grant-canary"), - Some("authority-canary"), + Err(TaskGrantError::Claims), serde_json::json!({"protected": "claim-canary"}), ); context.actor = Some("actor-canary".to_string()); @@ -958,11 +1088,92 @@ mod tests { "principal-canary", "actor-canary", "tag-canary", - "grant-canary", - "authority-canary", "claim-canary", ] { assert!(!debug.contains(canary)); } } + + fn context_extraction_authenticator(principal_claim: &str) -> Authenticator { + Authenticator::new( + Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + "https://issuer.invalid".to_owned(), + vec!["evidence-resource".to_owned()], + vec![jsonwebtoken::Algorithm::EdDSA], + vec!["at+jwt".to_owned()], + ) + .with_allowed_clients(vec!["evidence-agent".to_owned()]), + Arc::new(JwksFetcher::new( + "https://issuer.invalid/jwks".to_owned(), + JwksFetcherConfig::defaults(), + )), + )), + AuthenticationClaimsConfig { + principal_claim: principal_claim.to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + contextual_claims: ClaimNames::default(), + actor_claim: None, + }, + ) + .with_resources(vec!["evidence-resource".to_owned()]) + } + + #[test] + fn evidence_principal_must_equal_the_grant_subject() { + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::from_value(serde_json::json!({ + "iss": "https://issuer.invalid", + "aud": "evidence-resource", + "sub": "institutional-agent", + "evidence_principal": "different-principal", + "exp": now + 300, + "registry_actor_kind": "agent", + "registry_purpose": "eligibility-check", + "registry_grant_id": "grant-1", + "registry_grant_authority": "authority-1", + "registry_grant_source_issuer": "https://casework.invalid", + "registry_grant_client": "evidence-agent", + "registry_grant_resource": "evidence-resource", + "registry_grant_exp": now + 300, + "registry_grant_bounds": {"type":"evidence", "requirement":"urn:example:requirement"}, + "evidence_tags": ["caseworker"], + "evidence_audience": "https://relying-party.invalid" + })) + .expect("claims parse"); + let context = context_extraction_authenticator("evidence_principal") + .extract_context(VerifiedToken { + claims, + matched_client: Some("client_id:evidence-agent".to_owned()), + scopes: Vec::new(), + }) + .expect("verified product context extracts"); + assert_eq!(context.grant(), Err(TaskGrantError::PrincipalMismatch)); + } + + #[test] + fn purpose_only_service_token_is_not_a_task_grant() { + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::from_value(serde_json::json!({ + "iss": "https://issuer.invalid", + "aud": "evidence-resource", + "sub": "service-principal", + "exp": now + 300, + "registry_actor_kind": "service", + "registry_purpose": "standing-service", + "evidence_tags": ["service"], + "evidence_audience": "https://relying-party.invalid" + })) + .expect("claims parse"); + let context = context_extraction_authenticator("sub") + .extract_context(VerifiedToken { + claims, + matched_client: Some("client_id:evidence-agent".to_owned()), + scopes: Vec::new(), + }) + .expect("standing token extracts"); + assert_eq!(context.actor_kind(), ActorKind::Service); + assert!(matches!(context.grant(), Ok(None))); + } } diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index f5ba516893..a5b97b105f 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -545,6 +545,31 @@ impl EvidenceConfig { validate_named_map(&self.authority_profiles, 1, 128, |profile| { profile.validate() })?; + let task_grant_profiles = self + .authority_profiles + .iter() + .map(|(_, profile)| profile) + .filter(|profile| !profile.requester_clients.is_empty()) + .collect::>(); + if !task_grant_profiles.is_empty() { + let allowed_clients = + self.authentication + .allowed_clients + .as_ref() + .ok_or(ConfigError::Invalid( + "task-grant authority profiles require authentication allowedClients", + ))?; + if task_grant_profiles.iter().any(|profile| { + profile + .requester_clients + .iter() + .any(|client| !allowed_clients.contains(client)) + }) { + return invalid( + "task-grant requester clients must be admitted by authentication allowedClients", + ); + } + } validate_len(self.requirements.len(), 1, 128, "requirements")?; let mut requirement_ids = BTreeSet::new(); @@ -1595,13 +1620,34 @@ pub struct AuthenticationConfig { pub principal_claim: String, pub requester_tags_claim: String, pub evidence_audience_claim: String, - pub grant_id_claim: String, - pub grant_authority_claim: String, + /// Shared, direct contextual-authorization claim names. Product-specific + /// requester, actor identity, and relying-party audience claims remain + /// separate because they have Evidence-specific meaning. + #[serde(default)] + pub claims: registry_platform_oidc::ClaimNames, /// Maximum lifetime accepted for inbound access tokens. The verifier /// requires `iat`, requires `exp > iat`, and applies this bound. pub maximum_token_lifetime_seconds: u64, /// Emergency denylist applied before JWKS cache selection. pub revoked_key_ids: Vec, + /// Explicit machine-client admission, matched against the token's + /// `client_id`/`azp` the platform verifier already reads. Absent keeps + /// the issuer-vouched-client behavior; present requires a nonempty, + /// bounded, unique list and admits exactly those clients. + /// + /// Audience plus static issuer-governed attributes alone cannot establish + /// that the client was granted this resource's permission: an issuer may + /// issue a correctly signed token for a known resource with zero scopes + /// while still emitting the client's attributes. `required_scopes` closes + /// that gap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_clients: Option>, + /// Scopes every inbound token must carry, checked against the verified + /// token's scope set after signature verification and before any authority + /// claim is read. Absent keeps the no-scope-gate behavior; present + /// requires a nonempty, bounded, unique list of RFC 6749 scope-tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_scopes: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub actor_claim: Option, } @@ -1616,10 +1662,24 @@ impl AuthenticationConfig { if assurance_profile != AssuranceProfile::Local { return invalid("production and evidence-grade authentication requires HTTPS"); } - let origin = validate_local_mint_origin(&self.issuer)?; - if self.jwks_uri != format!("{origin}{LOCAL_MINT_JWKS_PATH}") { + // The local permission is for a supervised issuer on this + // deployment's own loopback: a canonical numeric origin, with the + // JWKS served from that exact origin. It is not a permission for + // private-network HTTP, and a JWKS origin or port other than the + // issuer's is not supervised by the issuer that vouches for it. + let origin = validate_local_issuer_origin(&self.issuer)?; + if jwks_uri.scheme() != "http" + || jwks_uri.host_str() != Some("127.0.0.1") + || jwks_uri.port() != issuer.port() + || !jwks_uri.path().starts_with('/') + || jwks_uri.query().is_some() + || jwks_uri.fragment().is_some() + || !jwks_uri.username().is_empty() + || jwks_uri.password().is_some() + || self.jwks_uri != format!("{origin}{}", jwks_uri.path()) + { return invalid( - "local authentication JWKS URI must use the issuer origin and Mint JWKS path", + "local authentication JWKS URI must use the exact issuer origin, an absolute path, and no query or fragment", ); } } else { @@ -1643,6 +1703,22 @@ impl AuthenticationConfig { 256, "authentication revokedKeyIds", )?; + // Admission lists are optional, but a present list is a statement the + // deployment means: an empty allowlist admits nothing and an empty + // scope requirement gates nothing, and both are almost certainly a + // mis-authored key rather than a deliberate posture. + if let Some(clients) = &self.allowed_clients { + validate_unique_strings(clients, 1, 32, 1, 128, "authentication allowedClients")?; + } + if let Some(scopes) = &self.required_scopes { + validate_unique_strings(scopes, 1, 32, 1, 256, "authentication requiredScopes")?; + if scopes + .iter() + .any(|scope| !registry_platform_httputil::valid_scope_token(scope)) + { + return invalid("authentication requiredScopes must be RFC 6749 scope-tokens"); + } + } if self .revoked_key_ids .iter() @@ -1652,24 +1728,43 @@ impl AuthenticationConfig { } // Ordered principal first, because `sub` is legitimate for that claim // alone and the shadowing check below reads the rest of the list. - let claims = [ + self.claims.validate().map_err(|_| { + ConfigError::Invalid("contextual authorization claim names are invalid") + })?; + let product_claims = [ Some(&self.principal_claim), Some(&self.requester_tags_claim), Some(&self.evidence_audience_claim), - Some(&self.grant_id_claim), - Some(&self.grant_authority_claim), self.actor_claim.as_ref(), ] .into_iter() .flatten() .collect::>(); - for claim in &claims { + for claim in &product_claims { validate_claim_name(claim)?; } // Two claims naming one member means the same value is read as two // different things: requester tags read as a principal, or a grant id // read as the authority that granted it. - if claims.iter().collect::>().len() != claims.len() { + let contextual_claims = [ + &self.claims.actor_kind, + &self.claims.purpose, + &self.claims.grant_id, + &self.claims.grant_authority, + &self.claims.grant_source_issuer, + &self.claims.grant_client, + &self.claims.grant_resource, + &self.claims.grant_exp, + &self.claims.grant_bounds, + &self.claims.approver, + ]; + if product_claims + .iter() + .chain(contextual_claims.iter()) + .collect::>() + .len() + != product_claims.len() + contextual_claims.len() + { return invalid("authority claim names must be distinct"); } // These are defined by the token itself, so reading authority out of one @@ -1680,20 +1775,39 @@ impl AuthenticationConfig { // `sub` is the exception, and only for the principal. It carries the // principal already, so naming it there reads the same value; naming it // anywhere else reads the principal as something it is not. - if claims + if product_claims .iter() .any(|claim| REGISTERED_JWT_CLAIMS.contains(&claim.as_str())) - || claims.iter().skip(1).any(|claim| claim.as_str() == "sub") + || product_claims + .iter() + .skip(1) + .any(|claim| claim.as_str() == "sub") { return invalid("authority claim names must not shadow registered JWT claims"); } Ok(()) } - pub(crate) fn uses_local_mint_http(&self, assurance_profile: AssuranceProfile) -> bool { - assurance_profile == AssuranceProfile::Local - && validate_local_mint_origin(&self.issuer).is_ok() - && self.jwks_uri == format!("{}{}", self.issuer, LOCAL_MINT_JWKS_PATH) + pub(crate) fn uses_local_issuer_http(&self, assurance_profile: AssuranceProfile) -> bool { + if assurance_profile != AssuranceProfile::Local { + return false; + } + let Ok(issuer) = Url::parse(&self.issuer) else { + return false; + }; + let Ok(jwks_uri) = Url::parse(&self.jwks_uri) else { + return false; + }; + issuer.scheme() == "http" + && jwks_uri.scheme() == "http" + && validate_local_issuer_origin(&self.issuer).is_ok() + && jwks_uri.host_str() == Some("127.0.0.1") + && jwks_uri.port() == issuer.port() + && jwks_uri.path().starts_with('/') + && jwks_uri.query().is_none() + && jwks_uri.fragment().is_none() + && jwks_uri.username().is_empty() + && jwks_uri.password().is_none() } } @@ -1711,9 +1825,7 @@ impl AuthenticationConfig { const REGISTERED_JWT_CLAIMS: [&str; 8] = ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id", "cnf"]; -const LOCAL_MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; - -fn validate_local_mint_origin(value: &str) -> Result<&str, ConfigError> { +fn validate_local_issuer_origin(value: &str) -> Result<&str, ConfigError> { let port = value .strip_prefix("http://127.0.0.1:") .filter(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) @@ -2797,6 +2909,10 @@ pub enum SourceAuthentication { /// it is absent. #[serde(default, skip_serializing_if = "Option::is_none")] audience: Option, + /// RFC 8707 resource indicator sent as a token-request form parameter. + /// Unlike the assertion audience, this names the intended resource server. + #[serde(default, skip_serializing_if = "Option::is_none")] + resource: Option, /// Where the shared client secret travels; set only with /// `clientSecretRef`. #[serde( @@ -2851,6 +2967,7 @@ impl SourceAuthentication { client_assertion_audience, scope, audience, + resource, credential_placement, maximum_cache_seconds, assumed_lifetime_seconds, @@ -2904,6 +3021,9 @@ impl SourceAuthentication { return invalid("OAuth audience is blank"); } } + if let Some(resource) = resource { + validate_oauth_resource(resource)?; + } if let Some(assumed_lifetime_seconds) = assumed_lifetime_seconds { validate_range( *assumed_lifetime_seconds, @@ -3530,6 +3650,15 @@ pub enum PreparationChannelPolicy { pub struct AuthorityProfile { pub kind: AuthorityKind, pub requester_tags: Vec, + /// Verified OAuth clients allowed to exercise a grant-bound authority + /// path. Required only when one of this profile's subjects is sourced from + /// an authenticated task grant. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requester_clients: Vec, + /// Trusted issuer that supplied the immutable grant context before token + /// exchange. The resource server compares it exactly with the signed grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_source_issuer: Option, pub grants: Vec, } @@ -3539,10 +3668,39 @@ impl AuthorityProfile { if self.requester_tags.iter().any(|tag| !valid_local_id(tag)) { return invalid("requester tag is invalid"); } + validate_unique_strings( + &self.requester_clients, + 0, + 32, + 1, + 128, + "authority requester clients", + )?; validate_len(self.grants.len(), 1, 128, "authority grants")?; for grant in &self.grants { grant.validate()?; } + let uses_task_grant = self.grants.iter().any(|grant| { + grant + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant) + }); + if uses_task_grant { + validate_len( + self.requester_clients.len(), + 1, + 32, + "task-grant requester clients", + )?; + let source = self + .grant_source_issuer + .as_deref() + .ok_or(ConfigError::Invalid( + "task-grant authority profile requires grantSourceIssuer", + ))?; + validate_uri(source)?; + } Ok(()) } } @@ -4794,6 +4952,20 @@ pub(crate) fn validate_local_unauthenticated_source_origin(value: &str) -> Resul Ok(()) } +/// Validate an RFC 8707 resource identifier without rewriting its governed bytes. +/// It is an identifier in a form body, not an origin Evidence will connect to. +pub(crate) fn validate_oauth_resource(value: &str) -> Result<(), ConfigError> { + let invalid_resource = || { + ConfigError::Invalid( + "OAuth resource must be an absolute URI without a fragment or user information", + ) + }; + if value.len() > 512 || !registry_platform_httputil::valid_resource_uri(value) { + return Err(invalid_resource()); + } + Ok(()) +} + fn validate_source_url(value: &str, origin_only: bool) -> Result { if !value.bytes().all(is_uri_byte) { return invalid("source URL contains characters a URI cannot carry"); @@ -5506,6 +5678,7 @@ mod tests { "clientAssertionKeyRef": "secret:file/client-key", "clientAssertionAudience": "https://issuer.example/client-auth", "audience": "https://resource.example", + "resource": "https://resource.example/records", "maximumCacheSeconds": 60 }, "tlsTrustProfile": "private-ca" @@ -5539,6 +5712,20 @@ mod tests { config.validate().is_err(), "a copied endpoint cannot retarget a named source" ); + config = before.clone(); + if let SourceConfig::HttpJson { authentication, .. } = &mut config.sources.0[0].1 { + if let SourceAuthentication::Oauth2ClientCredentials { resource, .. } = + authentication.as_mut() + { + *resource = Some("https://other.example/records".to_owned()); + } else { + panic!("expected OAuth source"); + } + } + assert!( + config.validate().is_err(), + "a source cannot retarget its named connection's OAuth resource" + ); for field in ["authentication", "tlsTrustProfile", "concurrencyLimit"] { config = before.clone(); if let SourceConfig::HttpJson { @@ -5699,6 +5886,9 @@ mod tests { /// /// Every acceptance fixture these tests parse declares one `http-json` /// source, so a test that mutates HTTP request material names the + /// + /// Every acceptance fixture these tests parse declares one `http-json` + /// source, so a test that mutates HTTP request material names the /// transport through these four helpers rather than at each call. fn http_base_url(config: &mut EvidenceConfig) -> &mut String { match &mut config.sources.0[0].1 { @@ -5763,8 +5953,12 @@ mod tests { assert!(parsed.requirements[0].fixtures.is_none()); } + /// The supervised-local issuer rule: a canonical numeric loopback origin + /// for the issuer, and a JWKS on that exact origin at any absolute path + /// without query or fragment. Local assurance only; strict profiles keep + /// the HTTPS requirement. #[test] - fn only_local_assurance_accepts_the_exact_loopback_mint_identity() { + fn only_local_assurance_accepts_a_canonical_loopback_issuer_with_same_origin_jwks() { let mut config = EvidenceConfig::parse_yaml(include_bytes!( "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" )) @@ -5774,7 +5968,12 @@ mod tests { config.authentication.jwks_uri = "http://127.0.0.1:8081/.well-known/jwks.json".to_owned(); config .validate() - .expect("local profile accepts the supervised Mint identity"); + .expect("local profile accepts the supervised loopback identity"); + // The JWKS path is the issuer's to choose now, not Mint's fixed route. + config.authentication.jwks_uri = "http://127.0.0.1:8081/oauth2/jwks".to_owned(); + config + .validate() + .expect("local profile accepts any same-origin absolute JWKS path"); for invalid in [ "http://localhost:8081", @@ -5794,10 +5993,17 @@ mod tests { ); } for invalid in [ - "http://127.0.0.1:8081/.well-known/keys.json", - "http://127.0.0.1:8082/.well-known/jwks.json", - "http://localhost:8081/.well-known/jwks.json", - "https://127.0.0.1:8081/.well-known/jwks.json", + // A different port, host, or scheme is not the issuer's origin. + "http://127.0.0.1:8082/oauth2/jwks", + "http://localhost:8081/oauth2/jwks", + "https://127.0.0.1:8081/oauth2/jwks", + // No path stated at all, or one carrying a query, fragment, or + // userinfo. The root path itself is an absolute path and stays + // legal: the rule fixes the origin, not the route. + "http://127.0.0.1:8081", + "http://127.0.0.1:8081/oauth2/jwks?cache=1", + "http://127.0.0.1:8081/oauth2/jwks#fragment", + "http://user@127.0.0.1:8081/oauth2/jwks", ] { let mut candidate = config.clone(); candidate.authentication.jwks_uri = invalid.to_owned(); @@ -5820,6 +6026,54 @@ mod tests { } } + /// The admission fields are optional, but a present list must be a + /// nonempty, bounded, unique list — and required scopes must be scope + /// tokens, because they are compared against verified token scopes. + #[test] + fn admission_lists_are_optional_but_present_means_nonempty_and_bounded() { + let mut config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("strict fixture validates"); + config + .validate() + .expect("absent admission fields keep the existing behavior"); + + config.authentication.allowed_clients = Some(vec!["records-reader".to_owned()]); + config.authentication.required_scopes = Some(vec!["evidence:invoke".to_owned()]); + config.validate().expect("stated admission validates"); + + for clients in [ + Vec::new(), + vec!["".to_owned()], + vec!["a".repeat(129)], + vec!["reader".to_owned(); 33], + vec!["reader".to_owned(), "reader".to_owned()], + ] { + let mut candidate = config.clone(); + candidate.authentication.allowed_clients = Some(clients.clone()); + assert!( + candidate.validate().is_err(), + "accepted allowedClients {clients:?}" + ); + } + for scopes in [ + Vec::new(), + vec!["".to_owned()], + vec!["a".repeat(257)], + vec!["not a scope".to_owned()], + vec!["evidence:invoke".to_owned(); 33], + vec!["evidence:invoke".to_owned(), "evidence:invoke".to_owned()], + ] { + let mut candidate = config.clone(); + candidate.authentication.required_scopes = Some(scopes.clone()); + assert!( + candidate.validate().is_err(), + "accepted requiredScopes {scopes:?}" + ); + } + } + #[test] fn publication_endpoint_requires_https_or_local_loopback_http() { let mut config = EvidenceConfig::parse_yaml(include_bytes!( @@ -6057,8 +6311,8 @@ mod tests { /// Mint is one possible issuer. Evidence is documented against any OIDC /// issuer, and no other issuer enforces Mint's rules, so the deployment with /// no issuer-side check is exactly the one where this is the only check. - /// `grantAuthorityClaim: aud` would read Evidence's own audience as the - /// authority that granted the request. + /// A grant authority claim named `aud` would read Evidence's own audience + /// as the authority that granted the request. #[test] fn authority_claim_names_must_be_distinct_and_must_not_shadow_registered_claims() { let config = EvidenceConfig::parse_yaml(include_bytes!( @@ -6072,11 +6326,12 @@ mod tests { let mut duplicate = config.clone(); duplicate .authentication - .grant_id_claim - .clone_from(&config.authentication.grant_authority_claim); + .claims + .grant_id + .clone_from(&config.authentication.claims.grant_authority); assert_eq!( duplicate.validate(), - invalid("authority claim names must be distinct"), + invalid("contextual authorization claim names are invalid"), "one member read as both the grant id and the granting authority" ); @@ -6089,6 +6344,15 @@ mod tests { "one member read as both the actor and the requester tags" ); + let mut shared_shadows_product = config.clone(); + shared_shadows_product.authentication.claims.purpose = + config.authentication.requester_tags_claim.clone(); + assert_eq!( + shared_shadows_product.validate(), + invalid("authority claim names must be distinct"), + "a shared claim cannot reuse an Evidence product claim" + ); + // `cnf` is here for a different reason than the rest. The others would // read a member the issuer owns; `cnf` would name one the authenticator // refuses outright, because Version 1 validates no proof of possession @@ -6098,10 +6362,10 @@ mod tests { // to explain why. for reserved in ["iss", "aud", "exp", "iat", "nbf", "jti", "client_id", "cnf"] { let mut candidate = config.clone(); - candidate.authentication.grant_authority_claim = reserved.to_owned(); + candidate.authentication.claims.grant_authority = reserved.to_owned(); assert_eq!( candidate.validate(), - invalid("authority claim names must not shadow registered JWT claims"), + invalid("contextual authorization claim names are invalid"), "grant authority read from the registered claim {reserved}" ); } @@ -6117,10 +6381,10 @@ mod tests { // colliding with the principal and tripping distinctness instead. let mut authority_is_subject = config.clone(); authority_is_subject.authentication.principal_claim = "evidence_principal".to_owned(); - authority_is_subject.authentication.grant_authority_claim = "sub".to_owned(); + authority_is_subject.authentication.claims.grant_authority = "sub".to_owned(); assert_eq!( authority_is_subject.validate(), - invalid("authority claim names must not shadow registered JWT claims"), + invalid("contextual authorization claim names are invalid"), "the granting authority read from the principal member" ); @@ -6131,6 +6395,44 @@ mod tests { .expect("distinct, unreserved claim names load"); } + #[test] + fn task_grant_profiles_bind_trusted_source_and_verified_client() { + let config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml" + )) + .expect("task-grant fixture validates"); + + let mut no_profile_clients = config.clone(); + no_profile_clients.authority_profiles.0[0] + .1 + .requester_clients + .clear(); + assert!(no_profile_clients.validate().is_err()); + + let mut no_source = config.clone(); + no_source.authority_profiles.0[0].1.grant_source_issuer = None; + assert_eq!( + no_source.validate(), + invalid("task-grant authority profile requires grantSourceIssuer") + ); + + let mut no_global_admission = config.clone(); + no_global_admission.authentication.allowed_clients = None; + assert_eq!( + no_global_admission.validate(), + invalid("task-grant authority profiles require authentication allowedClients") + ); + + let mut client_not_admitted = config; + client_not_admitted.authentication.allowed_clients = Some(vec!["other-client".to_owned()]); + assert_eq!( + client_not_admitted.validate(), + invalid( + "task-grant requester clients must be admitted by authentication allowedClients" + ) + ); + } + #[test] fn unauthenticated_source_is_local_loopback_only_and_matches_the_bundle_schema() { let mut local = EvidenceConfig::parse_yaml(include_bytes!( @@ -7835,6 +8137,7 @@ mod tests { client_assertion_audience: None, scope: None, audience: None, + resource: None, credential_placement: Some(CredentialPlacement::FormBody), maximum_cache_seconds: 60, assumed_lifetime_seconds: None, @@ -7887,6 +8190,7 @@ mod tests { client_assertion_audience: None, scope: None, audience: None, + resource: None, credential_placement: Some(CredentialPlacement::FormBody), maximum_cache_seconds: 60, assumed_lifetime_seconds, @@ -7934,6 +8238,52 @@ mod tests { } } + /// The resource indicator is an exact governed URI, not an outbound URL + /// or the client assertion audience. Invalid values fail at configuration + /// validation before a credential or token request can be made. + #[test] + fn oauth_resource_indicator_is_optional_and_rejects_relative_or_ambiguous_values() { + let validator = bundle_contract_validator(); + for (resource, accepted) in [ + (None, true), + (Some("https://api.invalid:443/records"), true), + (Some("https://api.invalid/records%20archive"), true), + (Some("https://[::1]/records"), true), + (Some("urn:example:records"), true), + (Some(""), false), + (Some("api.invalid/records"), false), + (Some("https://api.invalid/records#fragment"), false), + (Some("https://user@api.invalid/records"), false), + (Some("https://api.invalid/é"), false), + (Some("https://api.invalid/records with space"), false), + (Some("https://api.invalid/%GG"), false), + ] { + let mut authentication = serde_json::json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": "https://issuer.invalid/token", + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "credentialPlacement": "form-body", + "maximumCacheSeconds": 60, + }); + if let Some(resource) = resource { + authentication["resource"] = serde_json::json!(resource); + } + let parsed: SourceAuthentication = serde_json::from_value(authentication.clone()) + .expect("the closed authentication shape deserializes"); + assert_eq!(parsed.validate().is_ok(), accepted, "runtime: {resource:?}"); + let mut instance = bundle_contract_instance(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )); + instance["sources"]["source-a"]["authentication"] = authentication; + assert_eq!( + validator.is_valid(&instance), + accepted, + "schema: {resource:?}" + ); + } + } + /// The kind writes the Authorization header, and RFC 9110 section 11.1 /// makes the scheme a token the origin chooses. Deployed sources ask for /// schemes other than Bearer, and `static-api-key` cannot serve them diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index e38d5463ea..db653d1e03 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -15,6 +15,7 @@ use registry_platform_audit::{AuditError, AuditProfile}; use registry_platform_crypto::{ LocalJwkSigner, PrivateJwk, SigningProvider, TransitSigner, TransitSignerConfig, }; +use registry_platform_oidc::ActorKind; use serde_json::{Map as JsonMap, Value}; use thiserror::Error; @@ -1040,6 +1041,22 @@ impl EvidenceRuntime { }) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let refusal_client_pseudonym = context + .client() + .map(|client| { + self.audit + .pseudonym("client", &refusal_scope, client.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let refusal_grant_pseudonym = context + .grant_id() + .map(|grant| { + self.audit + .pseudonym("grant", &refusal_scope, grant.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; let binding_mode = self .bundle() @@ -1067,6 +1084,14 @@ impl EvidenceRuntime { }) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let client_pseudonym = context + .client() + .map(|client| { + self.audit + .pseudonym("client", &issuance_scope, client.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; // Resolve every authorization and selector set before constructing the // first source future. A refused later item therefore cannot make an @@ -1087,6 +1112,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1107,6 +1135,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1133,6 +1164,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1163,6 +1197,8 @@ impl EvidenceRuntime { purpose: batch.purpose.clone(), requester_pseudonym, actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym, items: authorized.iter().map(|item| item.audit.clone()).collect(), }; let issued_at = observed_at; @@ -1486,6 +1522,22 @@ impl EvidenceRuntime { }) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let refusal_client_pseudonym = context + .client() + .map(|client| { + self.audit + .pseudonym("client", &refusal_scope, client.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let refusal_grant_pseudonym = context + .grant_id() + .map(|grant| { + self.audit + .pseudonym("grant", &refusal_scope, grant.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; let matched = match match_entitlement(self.bundle(), request, &context) { Ok(matched) => matched, @@ -1494,6 +1546,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1515,6 +1570,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1567,6 +1625,9 @@ impl EvidenceRuntime { operation, refusal_requester_pseudonym, refusal_actor_pseudonym, + context.actor_kind(), + refusal_client_pseudonym, + refusal_grant_pseudonym, started, ) .await?; @@ -1612,10 +1673,20 @@ impl EvidenceRuntime { }) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let client_pseudonym = context + .client() + .map(|client| { + self.audit + .pseudonym("client", &issuance_scope, client.as_bytes()) + }) + .transpose() + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; let material = self.audit_material( &issuance_scope, requester_pseudonym, actor_pseudonym, + context.actor_kind(), + client_pseudonym, &resolved, format, )?; @@ -3103,6 +3174,8 @@ impl EvidenceRuntime { scope: &str, requester_pseudonym: String, actor_pseudonym: Option, + actor_kind: ActorKind, + client_pseudonym: Option, resolved: &ResolvedAuthorization, format: ResponseFormat, ) -> Result { @@ -3137,6 +3210,8 @@ impl EvidenceRuntime { purpose: resolved.purpose.clone(), requester_pseudonym, actor_pseudonym, + actor_kind, + client_pseudonym, authority: AuditAuthority { kind: map_authority_kind(resolved.authority_kind), grant_pseudonym, @@ -3151,6 +3226,9 @@ impl EvidenceRuntime { operation: &str, requester_pseudonym: String, actor_pseudonym: Option, + actor_kind: ActorKind, + client_pseudonym: Option, + grant_pseudonym: Option, started: Instant, ) -> Result<(), RuntimeFailure> { let mut event = EvidenceAuthorizationRefusalAuditEvent::new( @@ -3161,6 +3239,9 @@ impl EvidenceRuntime { elapsed_millis(started), ); event.actor_pseudonym = actor_pseudonym; + event.actor_kind = actor_kind; + event.client_pseudonym = client_pseudonym; + event.grant_pseudonym = grant_pseudonym; self.audit .append_authorization_refusal(event) .await @@ -3282,6 +3363,8 @@ struct RequestBatchAuditMaterial { purpose: String, requester_pseudonym: String, actor_pseudonym: Option, + actor_kind: ActorKind, + client_pseudonym: Option, items: Vec, } @@ -3305,6 +3388,8 @@ impl RequestBatchAuditMaterial { duration_milliseconds, ); event.actor_pseudonym = self.actor_pseudonym.clone(); + event.actor_kind = self.actor_kind; + event.client_pseudonym = self.client_pseudonym.clone(); event } @@ -3378,6 +3463,8 @@ struct AuditMaterial { purpose: String, requester_pseudonym: String, actor_pseudonym: Option, + actor_kind: ActorKind, + client_pseudonym: Option, authority: AuditAuthority, subjects: Vec, response_protection: ResponseProtection, @@ -3406,6 +3493,8 @@ impl AuditMaterial { duration_milliseconds, ); event.actor_pseudonym = self.actor_pseudonym.clone(); + event.actor_kind = self.actor_kind; + event.client_pseudonym = self.client_pseudonym.clone(); event } } diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 03cdd8fc0f..ee66a48446 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -587,8 +587,8 @@ async fn real_router_serves_all_definitions_concurrently_without_crossing_bounda .await; parent_discovery.assert_status_ok(); let parent_definitions = parent_discovery.json::(); - assert_eq!(parent_definitions.definitions.len(), 4); - let adult_definition = parent_definitions + assert_eq!(parent_definitions.definitions.len(), 1); + let adult_definition = standard_definitions .definitions .iter() .find(|definition| definition.requirement.ends_with(":adult-status:v1")) @@ -1323,6 +1323,8 @@ async fn discovery_requires_authentication_and_returns_no_unentitled_definitions "sub": "unentitled-discovery-principal", "iat": now - 1, "exp": now + 3600, + "client_id": "evidence-task-agent", + "registry_actor_kind": "service", "evidence_tags": ["unentitled-agency"], "evidence_audience": EVIDENCE_AUDIENCE })); @@ -2311,15 +2313,18 @@ async fn authorization_refusal_is_minimally_audited() { assert_eq!( record.keys().map(String::as_str).collect::>(), BTreeSet::from([ + "actorKind", "actorPseudonym", "assuranceProfile", "bundleRevision", + "clientPseudonym", "decision", "durationMilliseconds", "eventId", "occurredAt", "operation", "phase", + "reason", "requesterPseudonym", "safeErrorCategory", "schema", @@ -2332,7 +2337,10 @@ async fn authorization_refusal_is_minimally_audited() { assert_eq!(record["phase"], json!("denial")); assert_eq!(record["decision"], json!("not-authorized")); assert_eq!(record["safeErrorCategory"], json!("not-authorized")); + assert_eq!(record["actorKind"], json!("service")); + assert_eq!(record["reason"], json!("authorization.profile")); assert!(record["requesterPseudonym"].is_string()); + assert!(record["clientPseudonym"].is_string()); assert!(record["actorPseudonym"].is_string()); for protected in [ principal, @@ -2468,6 +2476,7 @@ async fn missing_principal_never_falls_back_to_client_id_or_azp() { "azp": "fallback-authorized-party-canary", "iat": now - 1, "exp": now + 3600, + "registry_actor_kind": "service", "evidence_tags": ["fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE })); @@ -2525,6 +2534,119 @@ async fn sender_constrained_tokens_are_denied_rather_than_downgraded() { } } +/// The admission gate: explicit allowed clients and a required scope, +/// checked after signature verification and before any authority claim is +/// read. This is the consumer half of the case where an issuer returns a +/// correctly signed token for a known resource with zero scopes while still +/// emitting the client's static attributes — audience plus attributes alone +/// must not gain Evidence access. The tokens are in-process, test-only signed +/// fixtures; they prove consumer enforcement, not any issuer's behavior. +#[tokio::test] +async fn admission_gates_refuse_unadmitted_clients_and_missing_scopes() { + let authenticator = admitted_authenticator(&["records-reader"], &["evidence:invoke"]); + + authenticator + .authenticate(&access_token(Some(json!({ + "client_id": "records-reader", + "scope": "records:read evidence:invoke", + })))) + .await + .expect("the admitted client carrying the required scope authenticates"); + + // The exact failure A04 names: the desired audience, the client's static + // attributes, a valid signature — and no scope grant for this resource. + for (label, extra) in [ + ( + "no scope claim at all", + json!({"client_id": "records-reader"}), + ), + ( + "an empty scope claim", + json!({"client_id": "records-reader", "scope": ""}), + ), + ( + "scopes that were narrowed to another resource", + json!({"client_id": "records-reader", "scope": "records:read"}), + ), + ] { + let error = authenticator + .authenticate(&access_token(Some(extra))) + .await + .expect_err(&format!("{label} was accepted")); + assert!( + matches!(error, AuthenticationError::Verification), + "{label}: {error:?}" + ); + } + + // A client outside the allowlist is refused even with every scope, and + // neither the principal nor the requester tags stand in for admission. + for (label, extra) in [ + ( + "an unadmitted client identifier", + json!({"client_id": "other-client", "scope": "evidence:invoke"}), + ), + ( + "no client identity at all", + json!({"scope": "evidence:invoke"}), + ), + ] { + let error = authenticator + .authenticate(&access_token(Some(extra))) + .await + .expect_err(&format!("{label} was accepted")); + assert!( + matches!(error, AuthenticationError::Verification), + "{label}: {error:?}" + ); + } + + /// An authenticator whose verifier carries the explicit client allowlist and + /// whose scope gate carries the required scopes, both as a generated + /// configuration would state them through `Authenticator::from_config`. + fn admitted_authenticator(allowed_clients: &[&str], required_scopes: &[&str]) -> Authenticator { + let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); + let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) + .expect("static auth JWKS parses"); + let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); + let verifier = Arc::new(TokenVerifier::new( + TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::ES256], + vec!["at+jwt".to_owned()], + ) + .with_allowed_clients(allowed_clients.iter().map(|id| id.to_string()).collect()), + fetcher, + )); + Authenticator::new( + verifier, + AuthenticationClaimsConfig { + principal_claim: "sub".to_owned(), + requester_tags_claim: "evidence_tags".to_owned(), + evidence_audience_claim: "evidence_audience".to_owned(), + contextual_claims: registry_platform_oidc::ClaimNames::default(), + actor_claim: None, + }, + ) + .with_required_scopes( + required_scopes + .iter() + .map(|scope| scope.to_string()) + .collect(), + ) + .with_resources(vec![TOKEN_AUDIENCE.to_owned()]) + } + + // Without stated admission the previous behavior stands: any + // issuer-vouched client, and no scope gate. + let permissive = authenticator_with_client_admission(None, None); + permissive + .authenticate(&access_token(Some(json!({"client_id": "other-client"})))) + .await + .expect("absent admission fields keep the existing behavior"); +} + #[tokio::test] async fn disclosure_audit_failure_prevents_signed_response_release() { let fixture = acceptance_runtime().await; @@ -6620,6 +6742,8 @@ async fn security_contract_rejects_unknown_and_unauthorized_requests_before_sour "sub": "unentitled-principal", "iat": now - 1, "exp": now + 3600, + "client_id": "evidence-task-agent", + "registry_actor_kind": "service", "evidence_tags": ["unentitled-tag"], "evidence_audience": EVIDENCE_AUDIENCE })); @@ -6754,6 +6878,8 @@ async fn failed_selector_budget_is_enforced_by_the_runtime_and_scoped_to_authori "sub": "shared-selector-principal", "iat": now - 1, "exp": now + 3600, + "client_id": "evidence-task-agent", + "registry_actor_kind": "service", "evidence_tags": ["alternate-fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE })); @@ -6969,15 +7095,13 @@ async fn one_runtime_proves_all_definitions_and_collapses_unresolved_relationshi .await .expect_err("caller candidate substitution is rejected before source access"); assert_eq!(error.problem(), ProblemCode::InvalidSelector); + let mut wrong_authority_claims = parent_grant_claims(); + wrong_authority_claims["registry_grant_authority"] = json!("different-authority"); let unauthorized = fixture .runtime .evaluate( "operation-acceptance-unauthorized", - &access_token(Some(json!({ - "evidence_grant_id": "grant-canary", - "evidence_authority": "different-authority", - "grant": {"candidate_parent": parent_candidate()} - }))), + &access_token(Some(wrong_authority_claims)), &parent_request(), ) .await @@ -7456,10 +7580,21 @@ async fn every_runtime_applicable_acceptance_case_reaches_terminal_audit_and_ver .await; let principal = format!("principal-{definition}-{index}"); - let token_claims = case + let fixture_claims = case .get("verified_token_claims") - .cloned() - .or_else(|| is_relationship.then(|| parent_grant_claims_for(parent_candidate()))); + .or_else(|| common.get("verified_token_claims")); + let token_claims = if is_relationship { + let mut claims = parent_grant_claims_for(parent_candidate()); + if let Some(Value::Object(fixture_claims)) = fixture_claims { + claims + .as_object_mut() + .expect("task grant claims are an object") + .extend(fixture_claims.clone()); + } + Some(claims) + } else { + fixture_claims.cloned() + }; let token = access_token_for(&principal, token_claims); let evaluation_time = acceptance_case_time(case, common); let audit_before = fs::read_to_string(&fixture.audit_path) @@ -9321,30 +9456,38 @@ fn authenticator_with_actor_claim(actor_claim: &str) -> Authenticator { } fn authenticator_with_optional_actor_claim(actor_claim: Option<&str>) -> Authenticator { + authenticator_with_client_admission(actor_claim, Some(vec!["evidence-task-agent".to_owned()])) +} + +fn authenticator_with_client_admission( + actor_claim: Option<&str>, + allowed_clients: Option>, +) -> Authenticator { let private = PrivateJwk::parse(AUTH_PRIVATE_JWK).expect("auth test key parses"); let jwks: JwkSet = serde_json::from_value(json!({"keys": [private.public()]})) .expect("static auth JWKS parses"); let fetcher = Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())); - let verifier = Arc::new(TokenVerifier::new( - TokenVerifierConfig::access_token_profile( - TOKEN_ISSUER, - vec![TOKEN_AUDIENCE.to_owned()], - vec![Algorithm::ES256], - vec!["at+jwt".to_owned()], - ), - fetcher, - )); + let mut verifier_config = TokenVerifierConfig::access_token_profile( + TOKEN_ISSUER, + vec![TOKEN_AUDIENCE.to_owned()], + vec![Algorithm::ES256], + vec!["at+jwt".to_owned()], + ); + if let Some(allowed_clients) = allowed_clients { + verifier_config = verifier_config.with_allowed_clients(allowed_clients); + } + let verifier = Arc::new(TokenVerifier::new(verifier_config, fetcher)); Authenticator::new( verifier, AuthenticationClaimsConfig { principal_claim: "sub".to_owned(), requester_tags_claim: "evidence_tags".to_owned(), evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), + contextual_claims: registry_platform_oidc::ClaimNames::default(), actor_claim: actor_claim.map(str::to_owned), }, ) + .with_resources(vec![TOKEN_AUDIENCE.to_owned()]) } /// The same authenticator, but resolving the issuer's keys over HTTP from a @@ -9360,7 +9503,8 @@ fn fetching_authenticator(jwks_uri: &str) -> Authenticator { vec![TOKEN_AUDIENCE.to_owned()], vec![Algorithm::ES256], vec!["at+jwt".to_owned()], - ), + ) + .with_allowed_clients(vec!["evidence-task-agent".to_owned()]), Arc::new(JwksFetcher::new_with_fetch_url_policy( jwks_uri.to_owned(), JwksFetcherConfig::defaults(), @@ -9373,11 +9517,11 @@ fn fetching_authenticator(jwks_uri: &str) -> Authenticator { principal_claim: "sub".to_owned(), requester_tags_claim: "evidence_tags".to_owned(), evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), + contextual_claims: registry_platform_oidc::ClaimNames::default(), actor_claim: None, }, ) + .with_resources(vec![TOKEN_AUDIENCE.to_owned()]) } fn access_token(extra: Option) -> String { @@ -9396,6 +9540,8 @@ fn access_token_for_issuer(issuer: &str, principal: &str, extra: Option) "sub": principal, "iat": now - 1, "exp": now + 298, + "client_id": "evidence-task-agent", + "registry_actor_kind": "service", "evidence_tags": ["fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE }); @@ -9430,9 +9576,20 @@ fn parent_grant_claims() -> Value { } fn parent_grant_claims_for(candidate: Value) -> Value { + let now = Utc::now().timestamp(); json!({ - "evidence_grant_id": "synthetic-parentage-grant-001", - "evidence_authority": AUTHORITY, + "registry_actor_kind": "agent", + "registry_grant_id": "synthetic-parentage-grant-001", + "registry_grant_authority": AUTHORITY, + "registry_grant_source_issuer": "https://casework.invalid", + "registry_grant_client": "evidence-task-agent", + "registry_grant_resource": TOKEN_AUDIENCE, + "registry_purpose": "fixture-enrolment", + "registry_grant_exp": now + 298, + "registry_grant_bounds": { + "type": "evidence", + "requirement": "urn:example:fixture:requirement:legal-parent-relationship:v1" + }, "grant": {"candidate_parent": candidate} }) } diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index b2798737f2..b4375612e8 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -3,6 +3,7 @@ use std::{collections::BTreeSet, fmt}; use chrono::NaiveDate; +use registry_platform_oidc::ActorKind; use serde_json::Value; use thiserror::Error; @@ -415,6 +416,11 @@ pub fn match_entitlement( request: &EvidenceRequest, context: &AuthenticatedContext, ) -> Result { + let has_task_grant = match context.grant() { + Ok(Some(_)) if context.actor_kind() == ActorKind::Agent => true, + Ok(Some(_)) | Err(_) => return Err(AuthorizationError::Unauthorized), + Ok(None) => false, + }; let requirement = bundle .config .requirements @@ -454,7 +460,21 @@ pub fn match_entitlement( .subjects .iter() .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); - if uses_authenticated_grant && context.grant_authority() != Some(authority_profile) { + // A token carrying a grant cannot fall back to a standing profile + // if any immutable grant binding fails. Conversely, a standing + // token cannot enter a grant-bound profile. + if uses_authenticated_grant != has_task_grant { + continue; + } + if uses_authenticated_grant + && !task_grant_matches( + context, + authority_profile, + authority, + &request.requirement, + &request.purpose, + ) + { continue; } matched.push(MatchedEntitlement { @@ -474,6 +494,31 @@ pub fn match_entitlement( } } +fn task_grant_matches( + context: &AuthenticatedContext, + authority_profile: &str, + authority: &crate::config::AuthorityProfile, + requirement: &str, + purpose: &str, +) -> bool { + let Ok(Some(grant)) = context.grant() else { + return false; + }; + context.actor_kind() == ActorKind::Agent + && grant.principal() == context.principal() + && grant.authority() == authority_profile + && authority.grant_source_issuer.as_deref() == Some(grant.source_issuer()) + && context.client().is_some_and(|client| { + client == grant.client() + && authority + .requester_clients + .iter() + .any(|allowed| allowed == client) + }) + && grant.purpose() == purpose + && grant.bounds().evidence_requirement() == Some(requirement) +} + /// Resolve the complete role-bound selector set for one matched entitlement. /// /// This is the first operation that inspects caller or authenticated-grant @@ -741,16 +786,20 @@ pub fn resolve_offline_fixture_authorization( subjects, holder_keys, }; - let (authority_name, authority) = bundle + let (authority_name, authority, configured_grant) = bundle .config .authority_profiles .iter() - .find(|(_, authority)| { - authority.grants.iter().any(|grant| { - grant.requirement == request.requirement - && grant.purpose == request.purpose - && same_subject_tuples(&grant.subjects, &request.subjects) - }) + .find_map(|(authority_name, authority)| { + authority + .grants + .iter() + .find(|grant| { + grant.requirement == request.requirement + && grant.purpose == request.purpose + && same_subject_tuples(&grant.subjects, &request.subjects) + }) + .map(|grant| (authority_name, authority, grant)) }) .ok_or(AuthorizationError::Unauthorized)?; let claims = case @@ -758,20 +807,78 @@ pub fn resolve_offline_fixture_authorization( .or_else(|| common.and_then(|value| value.get("verified_token_claims"))) .cloned() .unwrap_or_else(|| Value::Object(Default::default())); - let grant_id = claims - .get("evidence_grant_id") - .and_then(Value::as_str) - .map(ToOwned::to_owned); - let grant_authority = claims - .get("evidence_authority") - .and_then(Value::as_str) - .unwrap_or(authority_name) - .to_owned(); + let uses_task_grant = configured_grant + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant); + let (actor_kind, client, task_grant) = if uses_task_grant { + let client = authority + .requester_clients + .first() + .ok_or(AuthorizationError::Unauthorized)?; + let source_issuer = authority + .grant_source_issuer + .as_deref() + .ok_or(AuthorizationError::Unauthorized)?; + let resource = bundle + .config + .authentication + .audiences + .first() + .ok_or(AuthorizationError::Unauthorized)?; + let names = &bundle.config.authentication.claims; + let now = chrono::Utc::now().timestamp(); + let mut grant_claims = serde_json::json!({ + "sub": "offline-fixture-principal", + "exp": now + 300, + }); + let object = grant_claims + .as_object_mut() + .ok_or(AuthorizationError::Unauthorized)?; + object.insert( + names.grant_id.clone(), + Value::String("offline-fixture-grant".to_owned()), + ); + object.insert( + names.grant_authority.clone(), + Value::String(authority_name.to_owned()), + ); + object.insert( + names.grant_source_issuer.clone(), + Value::String(source_issuer.to_owned()), + ); + object.insert(names.grant_client.clone(), Value::String(client.to_owned())); + object.insert( + names.grant_resource.clone(), + Value::String(resource.to_owned()), + ); + object.insert( + names.purpose.clone(), + Value::String(request.purpose.clone()), + ); + object.insert(names.grant_exp.clone(), Value::from(now + 300)); + object.insert( + names.grant_bounds.clone(), + serde_json::json!({"type": "evidence", "requirement": request.requirement}), + ); + let parsed: registry_platform_oidc::Claims = + serde_json::from_value(grant_claims).map_err(|_| AuthorizationError::Unauthorized)?; + let grant = registry_platform_oidc::grant_claims( + &parsed, + names, + u64::try_from(now).map_err(|_| AuthorizationError::Unauthorized)?, + ) + .map_err(|_| AuthorizationError::Unauthorized)?; + (ActorKind::Agent, Some(client.as_str()), Ok(grant)) + } else { + (ActorKind::Service, None, Ok(None)) + }; let context = AuthenticatedContext::offline_fixture_context( authority.requester_tags.clone(), audience, - grant_id.as_deref(), - Some(&grant_authority), + actor_kind, + client, + task_grant, claims, ); Ok(authorize_and_resolve(bundle, &request, &context)?) @@ -1286,8 +1393,7 @@ mod tests { "principal", Vec::new(), "urn:example:relying-party", - None, - None, + Ok(None), Value::Null, ); let presented = holder_key_request(vec![holder_key(0), holder_key(1)]); diff --git a/crates/registry-evidence/src/source.rs b/crates/registry-evidence/src/source.rs index 276fb00d7b..fc09cd5f73 100644 --- a/crates/registry-evidence/src/source.rs +++ b/crates/registry-evidence/src/source.rs @@ -27,10 +27,10 @@ use zeroize::{Zeroize, Zeroizing}; use crate::bundle::{ArtifactFault, Bundle, SourceExtract}; use crate::config::{ is_http_token_byte, is_uri_byte, validate_local_unauthenticated_source_origin, - AcquisitionPosture, CredentialPlacement, DeclaredUnresolvedProblem, EvidenceConfig, - FixedRequest, HttpMethod, OutboundTlsConfig, PathBindingConfig, PreparationChannelPolicy, - SchemaFault, SecretRef, SelectorInput, SourceAuthentication, SourceConfig, - SourceConnectionConfig, SourceSelectorSet, SqliteParameterBinding, SqliteRequest, + validate_oauth_resource, AcquisitionPosture, CredentialPlacement, DeclaredUnresolvedProblem, + EvidenceConfig, FixedRequest, HttpMethod, OutboundTlsConfig, PathBindingConfig, + PreparationChannelPolicy, SchemaFault, SecretRef, SelectorInput, SourceAuthentication, + SourceConfig, SourceConnectionConfig, SourceSelectorSet, SqliteParameterBinding, SqliteRequest, RESERVED_SQL_PARAMETER, }; use crate::model::SelectorValue; @@ -662,6 +662,7 @@ struct OauthPlan { client_authentication: OauthClientAuthentication, scope: Option, audience: Option, + resource: Option, maximum_cache_lifetime: Duration, /// Lifetime used when the provider omits `expires_in`. assumed_lifetime: Option, @@ -1895,6 +1896,7 @@ fn compile_authentication( client_assertion_audience, scope, audience, + resource, credential_placement, maximum_cache_seconds, assumed_lifetime_seconds, @@ -1912,6 +1914,9 @@ fn compile_authentication( if token_endpoint.query().is_some() { return Err(SourceError::InvalidPlan); } + if let Some(resource) = resource { + validate_oauth_resource(resource).map_err(|_| SourceError::InvalidPlan)?; + } let client_authentication = match ( client_secret_ref, credential_placement, @@ -1941,6 +1946,7 @@ fn compile_authentication( client_authentication, scope: scope.clone(), audience: audience.clone(), + resource: resource.clone(), maximum_cache_lifetime: Duration::from_secs(*maximum_cache_seconds), assumed_lifetime: assumed_lifetime_seconds.map(Duration::from_secs), admission_timeout, @@ -2201,6 +2207,9 @@ impl OauthPlan { if let Some(audience) = self.audience.as_deref() { form.push(("audience", audience)); } + if let Some(resource) = self.resource.as_deref() { + form.push(("resource", resource)); + } // No supported form places a client credential in the request URI, // where proxy and ingress logs would capture it. let mut request = client @@ -3683,6 +3692,7 @@ mod tests { }, scope: Some("fixture.read".into()), audience: None, + resource: None, maximum_cache_lifetime: Duration::from_secs(60), assumed_lifetime: None, admission_timeout: Duration::from_millis(20), @@ -3762,6 +3772,7 @@ mod tests { }, scope: None, audience: None, + resource: None, maximum_cache_lifetime: Duration::from_secs(60), assumed_lifetime: None, admission_timeout: Duration::from_secs(5), diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index d0834b256f..f0c93d087c 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -303,16 +303,15 @@ async fn every_selector_profile_runs_the_complete_signed_service_path() { ), ( "selector-positive-grant", - access_token(json!({ - "evidence_grant_id": "synthetic-grant-001", - "evidence_authority": "authenticated-grant-v1", - "grant": {"subject": { + access_token(task_grant_claims( + "authenticated-grant-v1", + json!({ "given_name": "Adaeze", "family_name": "Okafor", "birth_date": "1990-07-11", "event_reference": "synthetic-event-001" - }} - })), + }), + )), grant_request(None), vec!["subject"], ), @@ -471,16 +470,15 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { "birth_date": "2000-02-29" } })); - let grant_token = access_token(json!({ - "evidence_grant_id": "synthetic-grant-001", - "evidence_authority": "authenticated-grant-v1", - "grant": {"subject": { + let grant_token = access_token(task_grant_claims( + "authenticated-grant-v1", + json!({ "given_name": "Adaeze", "family_name": "Okafor", "birth_date": "1990-07-11", "event_reference": "synthetic-event-001" - }} - })); + }), + )); let mut executed = BTreeSet::new(); assert_authorization_error( @@ -623,7 +621,8 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { executed.insert("caller-added-disambiguator-rejected-from-demographics-v1"); let grant_authority_without_id = access_token(json!({ - "evidence_authority": "authenticated-grant-v1", + "registry_actor_kind": "agent", + "registry_grant_authority": "authenticated-grant-v1", "grant": {"subject": { "given_name": "Adaeze", "family_name": "Okafor", @@ -635,20 +634,21 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { service .authorize(&grant_authority_without_id, &grant_request(None)) .await, - Err(AuthorizationStageError::Authentication) + Err(AuthorizationStageError::Authorization( + AuthorizationError::Unauthorized + )) )); executed.insert("authenticated-grant-id-not-bound"); - let wrong_grant_authority = access_token(json!({ - "evidence_grant_id": "synthetic-grant-001", - "evidence_authority": "other-authority-v1", - "grant": {"subject": { + let wrong_grant_authority = access_token(task_grant_claims( + "other-authority-v1", + json!({ "given_name": "Adaeze", "family_name": "Okafor", "birth_date": "1990-07-11", "event_reference": "synthetic-event-001" - }} - })); + }), + )); assert_authorization_error( &service, &wrong_grant_authority, @@ -961,6 +961,76 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { ); } +#[tokio::test] +async fn task_grant_context_is_bound_before_selector_or_source_access() { + let service = prepare_service(false).await; + let request = grant_request(None); + let subject = json!({ + "given_name": "Adaeze", + "family_name": "Okafor", + "birth_date": "1990-07-11", + "event_reference": "synthetic-event-001" + }); + + for (name, member, value) in [ + ( + "source issuer", + "registry_grant_source_issuer", + json!("https://other.invalid"), + ), + ("client", "registry_grant_client", json!("other-client")), + ( + "resource", + "registry_grant_resource", + json!("other-resource"), + ), + ("purpose", "registry_purpose", json!("other-purpose")), + ( + "requirement", + "registry_grant_bounds", + json!({"type":"evidence", "requirement":"urn:example:other"}), + ), + ] { + let mut claims = task_grant_claims("authenticated-grant-v1", subject.clone()); + claims[member] = value; + assert_authorization_error( + &service, + &access_token(claims), + &request, + AuthorizationError::Unauthorized, + ) + .await; + assert!(!name.is_empty()); + } + + let mut wrong_actor = task_grant_claims("authenticated-grant-v1", subject.clone()); + wrong_actor["registry_actor_kind"] = json!("service"); + assert_authorization_error( + &service, + &access_token(wrong_actor), + &request, + AuthorizationError::Unauthorized, + ) + .await; + + let mut expired = task_grant_claims("authenticated-grant-v1", subject); + expired["registry_grant_exp"] = json!(Utc::now().timestamp()); + assert_authorization_error( + &service, + &access_token(expired), + &request, + AuthorizationError::Unauthorized, + ) + .await; + + assert!(service + .server + .received_requests() + .await + .expect("source journal is readable") + .is_empty()); +} + #[test] fn configuration_selector_negatives_are_rejected_at_immutable_bundle_load() { assert_invalid_bundle(|text| { @@ -1084,7 +1154,8 @@ fn authenticator() -> Authenticator { vec![TOKEN_AUDIENCE.to_owned()], vec![Algorithm::EdDSA], vec!["at+jwt".to_owned()], - ), + ) + .with_allowed_clients(vec!["evidence-task-agent".to_owned()]), fetcher, )); Authenticator::new( @@ -1093,11 +1164,11 @@ fn authenticator() -> Authenticator { principal_claim: "sub".to_owned(), requester_tags_claim: "evidence_tags".to_owned(), evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), + contextual_claims: registry_platform_oidc::ClaimNames::default(), actor_claim: None, }, ) + .with_resources(vec![TOKEN_AUDIENCE.to_owned()]) } fn access_token(extra: Value) -> String { @@ -1108,6 +1179,8 @@ fn access_token(extra: Value) -> String { "sub": "selector-requester-principal-canary", "iat": now - 1, "exp": now + 3600, + "client_id": "evidence-task-agent", + "registry_actor_kind": "service", "evidence_tags": ["selector-reviewer"], "evidence_audience": EVIDENCE_AUDIENCE }); @@ -1120,6 +1193,25 @@ fn access_token(extra: Value) -> String { token_with_claims(claims) } +fn task_grant_claims(authority: &str, subject: Value) -> Value { + let now = Utc::now().timestamp(); + json!({ + "registry_actor_kind": "agent", + "registry_grant_id": "synthetic-grant-001", + "registry_grant_authority": authority, + "registry_grant_source_issuer": "https://casework.invalid", + "registry_grant_client": "evidence-task-agent", + "registry_grant_resource": TOKEN_AUDIENCE, + "registry_purpose": "fixture-procedure", + "registry_grant_exp": now + 300, + "registry_grant_bounds": { + "type": "evidence", + "requirement": "urn:example:fixture:requirement:property-with-event:v1" + }, + "grant": {"subject": subject} + }) +} + fn token_with_claims(claims: Value) -> String { let header = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&json!({ diff --git a/crates/registry-evidence/tests/source_contracts.rs b/crates/registry-evidence/tests/source_contracts.rs index a9724e9deb..7d90aef264 100644 --- a/crates/registry-evidence/tests/source_contracts.rs +++ b/crates/registry-evidence/tests/source_contracts.rs @@ -3349,6 +3349,7 @@ async fn a_private_key_jwt_source_sends_an_endpoint_audienced_assertion_and_no_s "clientIdRef": "secret:file/oauth-client-id", "clientAssertionKeyRef": "secret:file/oauth-client-key", "audience": "https://api.invalid/", + "resource": "https://api.invalid/records", "maximumCacheSeconds": 60 }), ); @@ -3379,6 +3380,7 @@ async fn a_private_key_jwt_source_sends_an_endpoint_audienced_assertion_and_no_s contains_parameter(&form, "grant_type", "client_credentials") && contains_parameter(&form, "client_id", "synthetic-client") && contains_parameter(&form, "audience", "https://api.invalid/") + && contains_parameter(&form, "resource", "https://api.invalid/records") && contains_parameter( &form, "client_assertion_type", @@ -3428,6 +3430,117 @@ async fn a_private_key_jwt_source_sends_an_endpoint_audienced_assertion_and_no_s ); } +/// RFC 8707 resource is a governed form parameter, independent of the +/// provider-specific audience field. It neither changes the token endpoint nor +/// becomes a credential in the URL, for either client-secret placement. +#[tokio::test] +async fn oauth_resource_indicator_is_sent_exactly_in_both_client_secret_forms() { + for placement in ["basic-header", "form-body"] { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "synthetic-access-token", "token_type": "Bearer", "expires_in": 60 + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/data")) + .and(header("authorization", "Bearer synthetic-access-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .expect(1) + .mount(&server) + .await; + let (_root, secrets) = resolver(&[ + ("oauth-client-id", "synthetic-client"), + ("oauth-client-secret", "synthetic-secret"), + ]); + let source = fixed_source( + &server.uri(), + json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": format!("{}/token", server.uri()), + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "credentialPlacement": placement, + "audience": "https://legacy.invalid/", + "resource": "https://api.invalid:443/records", + "maximumCacheSeconds": 60 + }), + ); + SourceExecutor::new(&source, secrets) + .expect("resource-bound OAuth source compiles") + .execute( + &[selector("record")], + &prepared_http_request(&RequestParts { + query: vec![], + body: Some(json!({})), + }), + Utc::now(), + ) + .await + .expect("resource-bound OAuth source request succeeds"); + let requests = server.received_requests().await.expect("request journal"); + let token = requests + .iter() + .find(|request| request.url.path() == "/token") + .expect("token request was recorded"); + assert!( + query_parameters(&token.url).is_empty(), + "token URL has a query" + ); + let form = encoded_parameters(&token.body); + assert!(contains_parameter( + &form, + "resource", + "https://api.invalid:443/records" + )); + assert!(contains_parameter( + &form, + "audience", + "https://legacy.invalid/" + )); + assert_eq!( + form.iter().filter(|(name, _)| name == "resource").count(), + 1 + ); + assert_eq!(form.len(), if placement == "form-body" { 5 } else { 3 }); + } +} + +#[test] +fn oauth_resource_indicator_rejects_unusable_identifiers_before_credentials() { + let (_root, secrets) = resolver(&[]); + for resource in [ + "", + "api.invalid/records", + "https://api.invalid/records#fragment", + "https://user@api.invalid/records", + "https://api.invalid/record with space", + "https://api.invalid/é", + "https://api.invalid/records\n", + ] { + let source = fixed_source( + "https://source.invalid", + json!({ + "kind": "oauth2-client-credentials", + "tokenEndpoint": "https://issuer.invalid/token", + "clientIdRef": "secret:file/oauth-client-id", + "clientSecretRef": "secret:file/oauth-client-secret", + "credentialPlacement": "form-body", + "resource": resource, + "maximumCacheSeconds": 60 + }), + ); + assert_eq!( + SourceExecutor::new(&source, Arc::clone(&secrets)).err(), + Some(SourceError::InvalidPlan), + "invalid resource was compiled" + ); + } +} + /// An authorization server behind a proxy, or one following the RFC 7523 /// revision that makes the issuer identifier the sole audience, expects a value /// the client never dials. RFC 7523 section 3 has the server compare that value diff --git a/crates/registry-evidencectl/Cargo.toml b/crates/registry-evidencectl/Cargo.toml index 07492a9b05..5e723d8d81 100644 --- a/crates/registry-evidencectl/Cargo.toml +++ b/crates/registry-evidencectl/Cargo.toml @@ -35,6 +35,7 @@ registry-evidence-client.workspace = true registry-language-server.workspace = true registry-platform-buildinfo.workspace = true registry-platform-crypto.workspace = true +registry-thunderid-tooling.workspace = true p256.workspace = true rhai.workspace = true rustix.workspace = true diff --git a/crates/registry-evidencectl/src/access.rs b/crates/registry-evidencectl/src/access.rs index 552f6479bd..27f122a471 100644 --- a/crates/registry-evidencectl/src/access.rs +++ b/crates/registry-evidencectl/src/access.rs @@ -144,7 +144,9 @@ pub(crate) struct ActiveClient { #[derive(Clone, Debug, PartialEq)] pub(crate) struct ActiveClientRegistration { pub(crate) client_id: String, - pub(crate) registration: Value, + pub(crate) public_jwks: String, + pub(crate) requester_tags: Vec, + pub(crate) evidence_audience: String, } pub fn run(command: AccessCommand) -> Result { @@ -218,7 +220,7 @@ fn add_client(args: &ClientAddArgs) -> Result { validate_identifier(policy_id, "policy")?; } let _lifecycle = dev::lock_project_lifecycle(&project)?; - let live = prepare_live_context(&project)?; + require_stopped_or_absent_session(&project)?; let policies = load_policy_documents(&project)?; validate_client_policies(&policy_ids, &policies)?; let existing_clients = load_client_documents_if_present(&project)?; @@ -278,22 +280,11 @@ fn add_client(args: &ClientAddArgs) -> Result { return Err(error); } - let reload_requested = - if let Err(error) = synchronize_live_client(&project, &document, live.as_ref()) { - return Err( - error.context("client was saved, but the running local session was not reloaded") - ); - } else { - live.is_some() - }; println!( "Added client {} with {}.", document.client_id, joined_policies(&document.policies) ); - if reload_requested { - println!("Registry Mint reload requested."); - } Ok(ExitCode::SUCCESS) } @@ -326,7 +317,7 @@ fn revoke_client(args: &ClientRevokeArgs) -> Result { let project = canonical_project(&args.project)?; validate_identifier(&args.client, "client")?; let _lifecycle = dev::lock_project_lifecycle(&project)?; - let live = prepare_live_context(&project)?; + require_stopped_or_absent_session(&project)?; let policies = load_policy_documents(&project)?; let path = client_document_path(&project, &args.client); let mut document = read_client_document(&path)?; @@ -334,29 +325,9 @@ fn revoke_client(args: &ClientRevokeArgs) -> Result { if document.status == ClientStatus::Revoked { bail!("client {} is already revoked", args.client); } - if let Some(context) = &live { - validate_path_mode( - &context - .generated_directory - .join(format!("{}.yaml", document.client_id)), - false, - PRIVATE_FILE_MODE, - )?; - } document.status = ClientStatus::Revoked; replace_yaml_atomic(&path, &document, PUBLIC_FILE_MODE)?; - let reload_requested = - if let Err(error) = synchronize_live_revocation(&project, &document, live.as_ref()) { - return Err( - error.context("client was revoked, but the running local session was not reloaded") - ); - } else { - live.is_some() - }; println!("Revoked client {}.", document.client_id); - if reload_requested { - println!("Registry Mint reload requested."); - } Ok(ExitCode::SUCCESS) } @@ -382,7 +353,7 @@ pub(crate) fn resolve_ready_client( }) } -/// Load active editable clients as exact Mint registration documents. +/// Load active editable clients as exact local issuer registrations. pub(crate) fn load_active_clients( project: &Path, policy_tags: &BTreeMap, @@ -420,94 +391,19 @@ pub(crate) fn load_active_clients( .collect::>>()?; registrations.push(ActiveClientRegistration { client_id: document.client_id.clone(), - registration: mint_registration(document, requester_tags), + public_jwks: serde_json::to_string(&json!({"keys": document.keys}))?, + requester_tags, + evidence_audience: document.evidence_audience.clone(), }); } Ok(registrations) } -fn mint_registration(document: &ClientDocument, requester_tags: Vec) -> Value { - json!({ - "clientId": document.client_id, - "principal": document.principal, - "evidenceAudience": document.evidence_audience, - "requesterTags": requester_tags, - "keys": document.keys, - }) -} - -#[derive(Clone, Debug)] -struct LiveContext { - policy_tags: BTreeMap, - generated_directory: PathBuf, -} - -fn prepare_live_context(project: &Path) -> Result> { - let Some(ready) = dev::try_load_ready_state(project)? else { - return Ok(None); - }; - if ready.access_policies.is_empty() { - bail!( - "the running local session uses the implicit tutorial caller; stop and restart it after defining access policies" - ); +fn require_stopped_or_absent_session(project: &Path) -> Result<()> { + if dev::try_load_ready_state(project)?.is_some() { + bail!("stop the local development session before changing clients; the next start reprovisions the complete issuer registration"); } - let policy_tags = ready - .access_policies - .into_iter() - .map(|policy| (policy.id, policy.requester_tag)) - .collect::>(); - // Validate the complete editable registry and exact policy generation - // before a mutation publishes anything. - load_active_clients(project, &policy_tags)?; - let generated_directory = project.join(".evidence/dev/generated/clients"); - validate_path_mode(&generated_directory, true, PRIVATE_DIRECTORY_MODE)?; - Ok(Some(LiveContext { - policy_tags, - generated_directory, - })) -} - -fn synchronize_live_client( - project: &Path, - document: &ClientDocument, - live: Option<&LiveContext>, -) -> Result<()> { - let Some(live) = live else { - return Ok(()); - }; - let registration = load_active_clients(project, &live.policy_tags)? - .into_iter() - .find(|registration| registration.client_id == document.client_id) - .ok_or_else(|| anyhow::anyhow!("new client is not active in the editable registry"))?; - let generated_path = live - .generated_directory - .join(format!("{}.yaml", document.client_id)); - write_new_yaml_atomic( - &generated_path, - ®istration.registration, - PRIVATE_FILE_MODE, - )?; - dev::request_mint_reload(project) -} - -fn synchronize_live_revocation( - project: &Path, - document: &ClientDocument, - live: Option<&LiveContext>, -) -> Result<()> { - let Some(live) = live else { - return Ok(()); - }; - // The remaining registry must still be a valid all-or-nothing snapshot. - load_active_clients(project, &live.policy_tags)?; - let generated_path = live - .generated_directory - .join(format!("{}.yaml", document.client_id)); - validate_path_mode(&generated_path, false, PRIVATE_FILE_MODE)?; - fs::remove_file(&generated_path) - .with_context(|| format!("removing revoked registration {}", generated_path.display()))?; - sync_directory(&live.generated_directory)?; - dev::request_mint_reload(project) + Ok(()) } fn validate_client_policies( diff --git a/crates/registry-evidencectl/src/authoring.rs b/crates/registry-evidencectl/src/authoring.rs index 5dc99b3351..7d2ed82b76 100644 --- a/crates/registry-evidencectl/src/authoring.rs +++ b/crates/registry-evidencectl/src/authoring.rs @@ -52,7 +52,7 @@ pub(crate) use registry_evidence_authoring::{ }; const LOCAL_URI_PREFIX: &str = "urn:registrystack:evidence:local:"; -const LOCAL_AUDIENCE: &str = "registry-evidence-local"; +const LOCAL_AUDIENCE: &str = "urn:registrystack:evidence:local:gateway"; const LOCAL_SIGNING_PRIVATE_FILENAME: &str = "signing-p256-private-jwk"; const LOCAL_SIGNING_PUBLIC_FILENAME: &str = "signing-p256-public.jwk.json"; const AUTHORITY_PROFILE_ID: &str = "local-caller"; @@ -194,22 +194,22 @@ enum CompileProfile { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct LocalServicePorts { pub(crate) evidence: u16, - pub(crate) mint: u16, + pub(crate) issuer: u16, } impl LocalServicePorts { - pub(crate) fn new(evidence: u16, mint: u16) -> Result { - if evidence == 0 || mint == 0 { + pub(crate) fn new(evidence: u16, issuer: u16) -> Result { + if evidence == 0 || issuer == 0 { bail!("local service ports must be non-zero"); } - if evidence == mint { - bail!("Evidence and Mint must use different local ports"); + if evidence == issuer { + bail!("Evidence and the local issuer must use different local ports"); } - Ok(Self { evidence, mint }) + Ok(Self { evidence, issuer }) } - pub(crate) fn mint_origin(self) -> String { - format!("http://127.0.0.1:{}", self.mint) + pub(crate) fn issuer_origin(self) -> String { + format!("http://127.0.0.1:{}", self.issuer) } pub(crate) fn evidence_origin(self) -> String { @@ -221,7 +221,7 @@ impl Default for LocalServicePorts { fn default() -> Self { Self { evidence: 8080, - mint: 8081, + issuer: 8081, } } } @@ -285,7 +285,7 @@ pub(crate) fn compile_local_project_with_target_inputs( source_connections: Value, outbound_tls: Value, ) -> Result { - LocalServicePorts::new(ports.evidence, ports.mint)?; + LocalServicePorts::new(ports.evidence, ports.issuer)?; let project_root = validate_project_root(project_root)?; validate_private_empty_staging(staging_root)?; validate_evidence_binary(evidence_bin)?; @@ -3395,7 +3395,7 @@ fn render_local_bundle( ports: LocalServicePorts, active_public_jwk_file: &str, ) -> Result { - let mint_origin = ports.mint_origin(); + let issuer_origin = ports.issuer_origin(); let selector_profiles = questions .iter() .flat_map(|question| &question.subjects) @@ -3457,16 +3457,15 @@ fn render_local_bundle( }, "authentication": { "kind": "oidc-access-token", - "issuer": mint_origin, + "issuer": issuer_origin, "audiences": [LOCAL_AUDIENCE], "tokenTypes": ["at+jwt"], - "algorithms": ["ES256"], - "jwksUri": format!("{mint_origin}/.well-known/jwks.json"), + "algorithms": ["RS256"], + "jwksUri": format!("{issuer_origin}/oauth2/jwks"), "principalClaim": "sub", "requesterTagsClaim": "evidence_tags", "evidenceAudienceClaim": "evidence_audience", - "grantIdClaim": "evidence_grant_id", - "grantAuthorityClaim": "evidence_authority", + "requiredScopes": ["evidence:invoke"], "maximumTokenLifetimeSeconds": 300, "revokedKeyIds": [], }, diff --git a/crates/registry-evidencectl/src/client.rs b/crates/registry-evidencectl/src/client.rs index 667cede0ba..63e37ab74d 100644 --- a/crates/registry-evidencectl/src/client.rs +++ b/crates/registry-evidencectl/src/client.rs @@ -14,6 +14,9 @@ use std::{ use anyhow::{bail, Context as _, Result}; use clap::{ArgGroup, Args, Subcommand}; +use registry_evidence_client::private_key_jwt::{ + valid_scope_token, MAXIMUM_REQUESTED_SCOPES, MAXIMUM_REQUESTED_SCOPE_BYTES, +}; use registry_evidence_client::{EvidenceClient, EvidenceClientProfile}; use registry_platform_crypto::canonicalize_json; use serde::{Deserialize, Serialize}; @@ -100,6 +103,23 @@ pub struct ProfileCreateArgs { #[arg(long)] expected_provider: Option, + /// Audience of the client assertion presented at the token endpoint, when + /// the issuer expects one other than the token endpoint URL (ThunderID + /// v1.0.1 expects the issuer string). This is not the token request's + /// resource. + #[arg(long, value_name = "AUDIENCE")] + client_assertion_audience: Option, + + /// RFC 8707 resource indicator requested with the token: the resource + /// server's registered identifier, not a URL to fetch. + #[arg(long, value_name = "URI")] + resource: Option, + + /// Scope requested with the token. Repeat for several; a requested scope + /// may narrow the client's registered permission set, never widen it. + #[arg(long = "scope", value_name = "SCOPE")] + scopes: Vec, + /// New owner-only profile file. #[arg(long, alias = "out")] output: PathBuf, @@ -128,6 +148,23 @@ pub(crate) struct ClientProfile { pub(crate) verification: VerificationProfile, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) expected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) oauth: Option, +} + +/// The token-request parameters a profile fixes ahead of discovery. The +/// members are independent: a deployment may state only the assertion +/// audience, or only the resource, and unstated members keep their +/// discovery-driven defaults. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ClientOauthProfile { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) client_assertion_audience: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) scopes: Option>, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -234,6 +271,57 @@ fn create_profile(args: ProfileCreateArgs) -> Result { let expected = (expected.audience.is_some() || expected.issuer.is_some() || expected.provider.is_some()) .then_some(expected); + let oauth = { + let oauth = ClientOauthProfile { + client_assertion_audience: args.client_assertion_audience, + resource: args.resource, + scopes: (!args.scopes.is_empty()).then_some(args.scopes), + }; + if let Some(audience) = oauth.client_assertion_audience.as_deref() { + validate_bounded_identifier(audience, 512, "client assertion audience")?; + } + if let Some(resource) = oauth.resource.as_deref() { + validate_bounded_identifier(resource, 2048, "resource indicator")?; + let parsed = + Url::parse(resource).context("the resource indicator must be an absolute URI")?; + if parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + bail!("the resource indicator must carry no fragment or userinfo"); + } + } + if let Some(scopes) = oauth.scopes.as_deref() { + if scopes.len() > MAXIMUM_REQUESTED_SCOPES { + bail!( + "at most {} scopes may be requested", + MAXIMUM_REQUESTED_SCOPES + ); + } + for scope in scopes { + validate_bounded_identifier( + scope, + MAXIMUM_REQUESTED_SCOPE_BYTES, + "requested scope", + )?; + if !valid_scope_token(scope) { + bail!("requested scope {scope:?} is not an RFC 6749 scope-token"); + } + } + if scopes + .iter() + .collect::>() + .len() + != scopes.len() + { + bail!("a requested scope may not repeat"); + } + } + (oauth.client_assertion_audience.is_some() + || oauth.resource.is_some() + || oauth.scopes.is_some()) + .then_some(oauth) + }; let profile = ClientProfile { schema: CLIENT_PROFILE_SCHEMA_V1.to_owned(), base_url: args.base_url.as_str().trim_end_matches('/').to_owned(), @@ -246,6 +334,7 @@ fn create_profile(args: ProfileCreateArgs) -> Result { clock_skew_seconds: args.clock_skew_seconds, }, expected, + oauth, }; let mut bytes = canonicalize_json(&serde_json::to_value(profile)?)?; EvidenceClientProfile::from_slice(&bytes) diff --git a/crates/registry-evidencectl/src/dev.rs b/crates/registry-evidencectl/src/dev.rs index 4e458ff704..89416eada1 100644 --- a/crates/registry-evidencectl/src/dev.rs +++ b/crates/registry-evidencectl/src/dev.rs @@ -2,7 +2,8 @@ //! //! The final `.evidence/dev` directory is compiled in place because the //! runtime contains absolute paths. A resident supervisor owns both service -//! children and is the only process allowed to stop them. +//! child and the retained issuer container, and is the only process allowed +//! to stop them. use std::{ collections::{BTreeMap, BTreeSet}, @@ -11,7 +12,7 @@ use std::{ net::TcpListener, os::unix::{ fs::{ - symlink, DirBuilderExt as _, FileTypeExt as _, MetadataExt as _, OpenOptionsExt as _, + DirBuilderExt as _, FileTypeExt as _, MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _, }, net::{UnixListener, UnixStream}, @@ -33,30 +34,32 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use zeroize::Zeroizing; +#[cfg(test)] +use std::os::unix::fs::symlink; + use crate::{ access, authoring::{ access_policy_requester_tag, compile_local_project_with_ports, compile_local_project_with_target_inputs, CompiledAccessPolicy, CompiledConceptForm, - CompiledProject, CompiledQuestion, LocalServicePorts, + CompiledQuestion, LocalServicePorts, }, keygen, OutputFormat, }; -const STATE_SCHEMA: &str = "registry.evidencectl.dev-state/v5"; +const STATE_SCHEMA: &str = "registry.evidencectl.dev-state/v6"; const CONTROL_SOCKET_NAME: &str = "control.sock"; const CALLER_ID: &str = "local-tutorial-caller"; -const LOCAL_ACCESS_TOKEN_AUDIENCE: &str = "registry-evidence-local"; +const LOCAL_ACCESS_TOKEN_AUDIENCE: &str = "urn:registrystack:evidence:local:gateway"; const LOCAL_CALLER_EVIDENCE_AUDIENCE: &str = "urn:registrystack:evidence:local:caller"; const LOCAL_REQUESTER_TAG: &str = "local-caller"; -const MINT_AUDIT_KEY_FILENAME: &str = "mint-audit-hmac-key"; const FAILED_START_LOGS: &str = "failed-start"; const RETAINED_STOPPED_SESSION: &str = "dev-stopped-before-restart"; const PRIVATE_DIR_MODE: u32 = 0o700; const PRIVATE_FILE_MODE: u32 = 0o600; const MAX_STATE_BYTES: u64 = 4 * 1024 * 1024; const MAX_HTTP_BODY_BYTES: u64 = 64 * 1024; -const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 45; +const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 120; const SHUTDOWN_TIMEOUT_SECONDS: u64 = 35; #[derive(Debug)] @@ -91,13 +94,35 @@ impl std::fmt::Display for DevStartFailure { impl std::error::Error for DevStartFailure {} +#[derive(Debug)] +pub(crate) struct RetiredMintDevelopment; + +impl std::fmt::Display for RetiredMintDevelopment { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the retired Registry Mint development flags were used") + } +} + +impl std::error::Error for RetiredMintDevelopment {} + +#[derive(Debug)] +pub(crate) struct TaskGrantAuthorityRequired; + +impl std::fmt::Display for TaskGrantAuthorityRequired { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("task grants must come from the configured Casework authority") + } +} + +impl std::error::Error for TaskGrantAuthorityRequired {} + #[derive(Debug, Args)] #[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)] pub struct DevArgs { #[command(subcommand)] action: Option, - /// Return after Registry Mint and Evidence Gateway are ready on loopback. + /// Return after the local issuer and Evidence Gateway are ready on loopback. #[arg(long)] detach: bool, @@ -105,8 +130,12 @@ pub struct DevArgs { #[arg(long, global = true)] evidence_port: Option, - /// Loopback port for the local Mint service. + /// Loopback port for the local issuer service. #[arg(long, global = true)] + issuer_port: Option, + + /// Retained Mint-era spelling, refused with migration guidance. + #[arg(long, global = true, hide = true)] mint_port: Option, /// Project root. Defaults to the current directory. @@ -121,9 +150,13 @@ pub struct DevArgs { #[arg(long, hide = true, global = true)] evidence_bin: Option, + /// Retained Mint-era spelling, refused with migration guidance. #[arg(long, hide = true, global = true)] mint_bin: Option, + #[arg(long, hide = true, global = true)] + docker_bin: Option, + #[arg( long, default_value_t = DEFAULT_READY_TIMEOUT_SECONDS, @@ -136,12 +169,33 @@ pub struct DevArgs { #[derive(Debug, Subcommand)] enum DevAction { - /// Start or restart the retained local Registry Mint and Evidence pair. + /// Start or restart the retained local issuer and Evidence pair. Start(StartArgs), - /// Stop the active local Registry Mint and Evidence Gateway pair. + /// Stop the active local issuer and Evidence Gateway pair. Stop(StopArgs), /// Remove one completed stopped local generation. Clean(CleanArgs), + /// Acquire a fresh local service token and report its private header-file path. + Token(TokenArgs), + /// Retained only to explain the authority-backed task-grant workflow. + #[command(hide = true)] + Grant(RetiredGrantArgs), +} + +#[derive(Debug, Args)] +#[command(trailing_var_arg = true)] +struct RetiredGrantArgs { + #[arg(value_name = "ARG", allow_hyphen_values = true)] + _arguments: Vec, +} + +#[derive(Debug, Args)] +struct TokenArgs { + /// Registered local client ID. + client: String, + /// Ready local project. Defaults to the current directory. + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, } #[derive(Debug, Args)] @@ -173,7 +227,7 @@ pub struct SupervisorArgs { #[arg(long)] evidence_bin: PathBuf, #[arg(long)] - mint_bin: PathBuf, + docker_bin: PathBuf, #[arg(long)] ready_timeout_seconds: u64, } @@ -191,8 +245,7 @@ enum DevStatus { #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] enum FailureKind { - MintStart, - MintReadiness, + IssuerStart, EvidenceStart, EvidenceReadiness, ChildExited, @@ -208,7 +261,8 @@ struct DevState { project: PathBuf, runtime_path: PathBuf, evidence_origin: String, - mint_origin: String, + issuer_origin: String, + issuer_session_id: String, token_url: String, access_token_audience: String, caller: Option, @@ -305,7 +359,7 @@ pub(crate) struct ReadyDevState { pub(crate) project: PathBuf, pub(crate) runtime_path: PathBuf, pub(crate) evidence_origin: String, - pub(crate) mint_origin: String, + pub(crate) issuer_origin: String, pub(crate) token_url: String, pub(crate) access_token_audience: String, pub(crate) caller: Option, @@ -364,14 +418,12 @@ pub(crate) struct LifecycleLock { #[derive(Default)] struct OwnedChildren { evidence: Option, - mint: Option, } impl OwnedChildren { fn stop(&mut self) { - stop_children(self.evidence.as_mut(), self.mint.as_mut()); + stop_child(self.evidence.as_mut()); self.evidence = None; - self.mint = None; } } @@ -382,16 +434,19 @@ impl Drop for OwnedChildren { } pub(crate) fn run_with_format(args: DevArgs, format: OutputFormat) -> Result { + if args.mint_port.is_some() || args.mint_bin.is_some() { + return Err(RetiredMintDevelopment.into()); + } match args.action { Some(DevAction::Start(start)) => { if args.detach || args.project.is_some() { bail!("`dev start` does not accept the compatibility flags --detach or --project"); } - let ports = selected_ports(&start.project, args.evidence_port, args.mint_port)?; + let ports = selected_ports(&start.project, args.evidence_port, args.issuer_port)?; start_detached( &start.project, args.evidence_bin.as_deref(), - args.mint_bin.as_deref(), + args.docker_bin.as_deref(), args.ready_timeout_seconds, ports, args.target.as_deref(), @@ -415,16 +470,18 @@ pub(crate) fn run_with_format(args: DevArgs, format: OutputFormat) -> Result fresh_token(&token.project, &token.client, format), + Some(DevAction::Grant(_)) => Err(TaskGrantAuthorityRequired.into()), None => { if !args.detach { bail!("the local development lifecycle requires `evidencectl dev --detach`"); } let project = args.project.as_deref().unwrap_or_else(|| Path::new(".")); - let ports = selected_ports(project, args.evidence_port, args.mint_port)?; + let ports = selected_ports(project, args.evidence_port, args.issuer_port)?; start_detached( project, args.evidence_bin.as_deref(), - args.mint_bin.as_deref(), + args.docker_bin.as_deref(), args.ready_timeout_seconds, ports, args.target.as_deref(), @@ -437,9 +494,9 @@ pub(crate) fn run_with_format(args: DevArgs, format: OutputFormat) -> Result, - mint_port: Option, + issuer_port: Option, ) -> Result { - let retained = if evidence_port.is_none() || mint_port.is_none() { + let retained = if evidence_port.is_none() || issuer_port.is_none() { canonical_project(project) .ok() .and_then(|project| { @@ -453,7 +510,7 @@ fn selected_ports( .and_then(|state| { Some(( state.evidence_origin.rsplit(':').next()?.parse().ok()?, - state.mint_origin.rsplit(':').next()?.parse().ok()?, + state.issuer_origin.rsplit(':').next()?.parse().ok()?, )) }) } else { @@ -463,8 +520,88 @@ fn selected_ports( evidence_port .or(retained.map(|ports| ports.0)) .unwrap_or(8080), - mint_port.or(retained.map(|ports| ports.1)).unwrap_or(8081), + issuer_port + .or(retained.map(|ports| ports.1)) + .unwrap_or(8081), + ) +} + +fn fresh_token(project: &Path, client_id: &str, format: OutputFormat) -> Result { + if !valid_local_identifier(client_id) { + bail!("a registered bounded local client ID is required"); + } + let ready = load_ready_state(project)?; + let private_key_path = if let Some(caller) = &ready.caller { + if caller.client_id != client_id { + bail!("the local client is not registered"); + } + caller.private_key_path.clone() + } else { + let policy_tags = ready + .access_policies + .iter() + .map(|policy| (policy.id.clone(), policy.requester_tag.clone())) + .collect::>(); + access::resolve_ready_client(&ready.project, client_id, &policy_tags)?.private_key_path + }; + let token = obtain_issuer_token(&ready, client_id, &private_key_path)?; + let output = ready + .project + .join(".evidence/dev/generated/keys") + .join(format!("{client_id}.header")); + let mut header = Zeroizing::new(b"Authorization: Bearer ".to_vec()); + header.extend_from_slice(token.as_bytes()); + header.push(b'\n'); + replace_private_file(&output, &header)?; + match format { + OutputFormat::Human => println!("Wrote fresh authorization header to {}", output.display()), + OutputFormat::Json => println!( + "{}", + json!({"operation":"dev-token","status":"ready","headerFile":output}) + ), + } + Ok(ExitCode::SUCCESS) +} + +pub(crate) fn obtain_issuer_token( + ready: &ReadyDevState, + client_id: &str, + private_key_path: &Path, +) -> Result> { + use registry_evidence_client::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; + + let key_bytes = read_owner_file(private_key_path, 16 * 1024)?; + let key_text = std::str::from_utf8(&key_bytes) + .context("the retained client assertion key is unreadable")?; + let key = registry_platform_crypto::PrivateJwk::parse(key_text) + .map_err(|_| anyhow!("the retained client assertion key is unusable"))?; + let endpoint = ready + .token_url + .parse() + .context("the local issuer token endpoint is invalid")?; + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new(endpoint, client_id.to_owned(), key) + .with_audience(ready.issuer_origin.clone()) + .with_resource(ready.access_token_audience.clone()) + .with_scopes(["evidence:invoke".to_owned()]), ) + .map_err(|error| anyhow!("the local token provider is unusable: {error}"))?; + let token = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("cannot build the local token runtime")? + .block_on(provider.bearer_token()) + .map_err(|error| anyhow!("the local issuer declined to issue a token: {error}"))?; + let value = token.authorization_header_value(); + let compact = value + .to_str() + .context("the issued credential is not header-safe")? + .strip_prefix("Bearer ") + .unwrap_or_default(); + if compact.len() > 64 * 1024 || compact.split('.').count() != 3 { + bail!("the local issuer returned an invalid compact token"); + } + Ok(Zeroizing::new(compact.to_owned())) } pub fn run_supervisor(args: SupervisorArgs) -> Result { @@ -513,7 +650,7 @@ pub(crate) fn load_ready_state(project: &Path) -> Result { project, runtime_path: state.runtime_path, evidence_origin: state.evidence_origin, - mint_origin: state.mint_origin, + issuer_origin: state.issuer_origin, token_url: state.token_url, access_token_audience: state.access_token_audience, caller: state.caller.map(|caller| ReadyCallerState { @@ -585,26 +722,7 @@ pub(crate) fn load_stopped_state(project: &Path) -> Result { }) } -/// Ask the private local supervisor to make Mint reload its complete client -/// registry. This confirms only that SIGHUP was delivered. The next token -/// request remains the functional proof that Mint accepted the new registry. -#[allow(dead_code)] // Consumed by the access-management CLI slice. -pub(crate) fn request_mint_reload(project: &Path) -> Result<()> { - let project = canonical_project(project)?; - let generated_root = existing_private_generated_root(&project)?; - let dev_root = generated_root.join("dev"); - validate_private_directory(&dev_root)?; - let state = read_state(&dev_root.join("state.json"))?; - if state.status != DevStatus::Ready || state.failure.is_some() { - bail!("the local development state is not ready for a Mint reload"); - } - validate_closed_state(&state, &project, &dev_root)?; - let socket = dev_root.join(CONTROL_SOCKET_NAME); - validate_control_socket(&socket)?; - send_control_request(&socket, b"reload-mint\n", b"reload-requested\n") - .context("the local supervisor did not accept the Mint reload request") -} - +#[cfg(test)] fn send_control_request(socket: &Path, request: &[u8], expected: &[u8]) -> Result<()> { let parent = socket .parent() @@ -631,9 +749,14 @@ fn send_control_request(socket: &Path, request: &[u8], expected: &[u8]) -> Resul fn validate_closed_state(state: &DevState, project: &Path, dev_root: &Path) -> Result<()> { let evidence_port = local_origin_port(&state.evidence_origin); - let mint_port = local_origin_port(&state.mint_origin); - let origins_are_closed = matches!((evidence_port, mint_port), (Some(evidence), Some(mint)) if evidence != mint) - && state.token_url == format!("{}/token", state.mint_origin); + let issuer_port = local_origin_port(&state.issuer_origin); + let origins_are_closed = matches!((evidence_port, issuer_port), (Some(evidence), Some(issuer)) if evidence != issuer) + && state.token_url == format!("{}/oauth2/token", state.issuer_origin) + && state.issuer_session_id.len() == 48 + && state + .issuer_session_id + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')); let questions_are_closed = !state.questions.is_empty() && state.questions.len() <= 128 && state.questions.iter().all(valid_question_state) @@ -673,10 +796,9 @@ fn validate_closed_state(state: &DevState, project: &Path, dev_root: &Path) -> R } DevStatus::Stopped => state.caller.is_none(), DevStatus::Stopping | DevStatus::Failed => true, - } && state - .caller - .as_ref() - .is_none_or(|caller| validate_closed_caller(caller, dev_root, &state.token_url).is_ok()); + } && state.caller.as_ref().is_none_or(|caller| { + validate_closed_caller(caller, dev_root, &state.issuer_origin).is_ok() + }); if state.project != project || state.runtime_path != dev_root.join("runtime.yaml") || !origins_are_closed @@ -979,10 +1101,14 @@ fn grant_matches_question(grant: &Value, question: &QuestionState) -> bool { }) } -fn validate_closed_caller(caller: &CallerState, dev_root: &Path, token_url: &str) -> Result<()> { +fn validate_closed_caller( + caller: &CallerState, + dev_root: &Path, + issuer_origin: &str, +) -> Result<()> { if caller.client_id != CALLER_ID || caller.private_key_path != dev_root.join("generated/keys/caller-private.jwk") - || caller.assertion_audience != token_url + || caller.assertion_audience != issuer_origin || caller.evidence_audience != LOCAL_CALLER_EVIDENCE_AUDIENCE || caller.requester_tag != LOCAL_REQUESTER_TAG { @@ -1012,7 +1138,7 @@ fn valid_local_identifier(value: &str) -> bool { fn start_detached( project: &Path, evidence_override: Option<&Path>, - mint_override: Option<&Path>, + docker_override: Option<&Path>, ready_timeout_seconds: u64, ports: LocalServicePorts, target: Option<&Path>, @@ -1041,7 +1167,7 @@ fn start_detached( &project, &dev_root, evidence_override, - mint_override, + docker_override, ready_timeout_seconds, ports, target, @@ -1084,7 +1210,7 @@ fn start_detached( fn probe_local_ports(ports: LocalServicePorts) -> Result<()> { for (port, service, flag) in [ (ports.evidence, "Evidence Gateway", "--evidence-port"), - (ports.mint, "Registry Mint", "--mint-port"), + (ports.issuer, "issuer", "--issuer-port"), ] { if let Err(error) = TcpListener::bind(("127.0.0.1", port)) { if error.kind() == std::io::ErrorKind::AddrInUse { @@ -1315,7 +1441,7 @@ fn prepare_and_start( project: &Path, dev_root: &Path, evidence_override: Option<&Path>, - mint_override: Option<&Path>, + docker_override: Option<&Path>, ready_timeout_seconds: u64, ports: LocalServicePorts, target: Option<&Path>, @@ -1326,10 +1452,10 @@ fn prepare_and_start( evidence_override, "EVIDENCECTL_TEST_EVIDENCE_BIN", )?)?; - let mint_bin = canonical_tool_binary(resolve_tool_binary( - "mint", - mint_override, - "EVIDENCECTL_TEST_MINT_BIN", + let docker_bin = command_binary(resolve_tool_binary( + "docker", + docker_override, + "EVIDENCECTL_TEST_DOCKER_BIN", )?)?; let compiled = { let _project_lock = crate::source_import::ProjectLock::acquire(project)?; @@ -1345,7 +1471,7 @@ fn prepare_and_start( outbound_tls, )?; if format == OutputFormat::Human { - println!("Local caller rehearsal uses the target's source connections and outbound TLS; Evidence and Mint use generated local governance."); + println!("Local caller rehearsal uses the target's source connections and outbound TLS; Evidence and the local issuer use generated local governance."); } compiled } @@ -1353,36 +1479,40 @@ fn prepare_and_start( } }; let evidence_origin = local_origin(ports.evidence); - let mint_origin = local_origin(ports.mint); - let token_url = format!("{mint_origin}/token"); + let issuer_origin = local_origin(ports.issuer); + let token_url = format!("{issuer_origin}/oauth2/token"); let generated = dev_root.join("generated"); let keys = generated.join("keys"); - let clients = generated.join("clients"); - let mint_audit = generated.join("audit"); let logs = dev_root.join("logs"); - for directory in [&generated, &keys, &clients, &mint_audit] { + for directory in [&generated, &keys] { create_private_directory(directory)?; } - let mint_public = generate_service_and_holder_keys(&keys)?; - let mint_audit_key = keys.join(MINT_AUDIT_KEY_FILENAME); - generate_mint_audit_key(&mint_audit_key)?; - let mint_config = mint_config(&compiled, &mint_public, &keys, ports); - let mint_config_path = generated.join("mint.yaml"); - write_private_yaml(&mint_config_path, &mint_config)?; + generate_holder_key(&keys)?; + let mut issuer_clients = Vec::new(); let caller = if compiled.access_policies.is_empty() { let (caller_private, caller_public) = keygen::generate_dev_keypair(&keys, "caller-private.jwk", "caller-public.jwk.json")?; let caller_public = read_owner_json(&caller_public, 16 * 1024)?; - write_private_yaml( - &clients.join("caller.yaml"), - &local_caller_registration(&compiled, caller_public), - )?; + issuer_clients.push(registry_thunderid_tooling::local::TypedLocalClient { + client_id: CALLER_ID.to_owned(), + public_jwks: serde_json::to_string(&json!({"keys":[caller_public]}))?, + claims: BTreeMap::from([ + ("registry_actor_kind".to_owned(), json!("service")), + ("evidence_tags".to_owned(), json!([compiled.requester_tag])), + ( + "evidence_audience".to_owned(), + json!(compiled.caller_evidence_audience), + ), + ]), + scopes: vec!["evidence:invoke".to_owned()], + allow_human_fixture: false, + }); Some(CallerState { client_id: CALLER_ID.to_owned(), private_key_path: caller_private, - assertion_audience: token_url.clone(), + assertion_audience: issuer_origin.clone(), evidence_audience: compiled.caller_evidence_audience.clone(), requester_tag: compiled.requester_tag.clone(), }) @@ -1397,14 +1527,41 @@ fn prepare_and_start( bail!("explicit access policies require at least one active client"); } for registration in registrations { - write_private_yaml( - &clients.join(format!("{}.yaml", registration.client_id)), - ®istration.registration, - )?; + issuer_clients.push(registry_thunderid_tooling::local::TypedLocalClient { + client_id: registration.client_id, + public_jwks: registration.public_jwks, + claims: BTreeMap::from([ + ("registry_actor_kind".to_owned(), json!("service")), + ( + "evidence_tags".to_owned(), + json!(registration.requester_tags), + ), + ( + "evidence_audience".to_owned(), + json!(registration.evidence_audience), + ), + ]), + scopes: vec!["evidence:invoke".to_owned()], + allow_human_fixture: false, + }); } None }; - run_check(&mint_bin, &["check", "--config"], &mint_config_path, "Mint")?; + let mut random = [0_u8; 24]; + getrandom::fill(&mut random)?; + let issuer_session_id = hex::encode(random); + let issuer_label = format!("evidence-dev-{}", &issuer_session_id[..12]); + let description = registry_thunderid_tooling::local::typed_local_description( + registry_thunderid_tooling::description::SessionIdentity { + label: issuer_label, + id: issuer_session_id.clone(), + }, + ports.issuer, + generated.join("issuer"), + compiled.local_audience.clone(), + issuer_clients, + )?; + registry_thunderid_tooling::render::render(&description)?; let state = DevState { schema: STATE_SCHEMA.to_owned(), @@ -1412,7 +1569,8 @@ fn prepare_and_start( project: project.to_path_buf(), runtime_path: compiled.runtime_path.clone(), evidence_origin: evidence_origin.clone(), - mint_origin: mint_origin.clone(), + issuer_origin: issuer_origin.clone(), + issuer_session_id, token_url: token_url.clone(), access_token_audience: compiled.local_audience.clone(), caller, @@ -1441,8 +1599,8 @@ fn prepare_and_start( .arg(dev_root) .arg("--evidence-bin") .arg(&evidence_bin) - .arg("--mint-bin") - .arg(&mint_bin) + .arg("--docker-bin") + .arg(&docker_bin) .arg("--ready-timeout-seconds") .arg(ready_timeout_seconds.to_string()) .stdin(Stdio::null()) @@ -1467,7 +1625,7 @@ fn prepare_and_start( match format { OutputFormat::Human => { println!("Evidence ready at {evidence_origin}"); - println!("Mint ready at {mint_origin}"); + println!("Issuer ready at {issuer_origin}"); } OutputFormat::Json => println!( "{}", @@ -1476,7 +1634,8 @@ fn prepare_and_start( "status": "ready", "project": project, "evidenceOrigin": evidence_origin, - "mintOrigin": mint_origin, + "issuer": issuer_origin, + "tokenEndpoint": token_url, "proofBoundary": "both retained local services reached readiness" }) ), @@ -1484,55 +1643,20 @@ fn prepare_and_start( Ok(ExitCode::SUCCESS) } -fn generate_service_and_holder_keys(keys: &Path) -> Result { - for name in [ - "mint-private.jwk", - "mint-public.jwk.json", - "holder-private.jwk", - "holder-public.jwk.json", - ] { +fn generate_holder_key(keys: &Path) -> Result<()> { + for name in ["holder-private.jwk", "holder-public.jwk.json"] { match fs::symlink_metadata(keys.join(name)) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Ok(_) => bail!("refusing to replace existing private dev key material"), Err(error) => return Err(error).context("inspecting private dev key material"), } } - let (_mint_private, staged_mint_public) = - keygen::generate_dev_keypair(keys, "mint-private.jwk", "mint-public.jwk.json")?; - let mint_public = publish_thumbprint_named_public_jwk(&staged_mint_public)?; // Keep one disposable holder pair beside the other private local session // keys so wallet-binding examples need no extra setup. Evidence does not // consume the private half and neither half leaves supervised dev state. let _holder = keygen::generate_dev_keypair(keys, "holder-private.jwk", "holder-public.jwk.json")?; - Ok(mint_public) -} - -fn publish_thumbprint_named_public_jwk(staged: &Path) -> Result { - let bytes = read_owner_file(staged, 16 * 1024)?; - let encoded = std::str::from_utf8(&bytes).context("generated public JWK is not UTF-8")?; - let public = registry_platform_crypto::PublicJwk::parse(encoded) - .context("generated public JWK failed validation")?; - let kid = public - .kid - .as_deref() - .ok_or_else(|| anyhow!("generated public JWK has no key id"))?; - if public - .jkt() - .context("generated public JWK has no thumbprint")? - != kid - { - bail!("generated public JWK key id is not its RFC 7638 thumbprint"); - } - let published = staged - .parent() - .ok_or_else(|| anyhow!("generated public JWK has no parent directory"))? - .join(format!("{kid}.jwk.json")); - let mut file = create_private_file(&published)?; - file.write_all(&bytes)?; - file.sync_all()?; - fs::remove_file(staged).context("failed to remove the staged public JWK")?; - Ok(published) + Ok(()) } fn stop_dev(project: &Path, format: OutputFormat) -> Result { @@ -1685,38 +1809,27 @@ fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { listener.set_nonblocking(true)?; injected_supervisor_failure("after-socket")?; - let mut children = OwnedChildren::default(); - children.mint = Some( - match spawn_service( - &args.mint_bin, - &["serve", "--config"], - &dev_root.join("generated/mint.yaml"), - &dev_root.join("logs/mint.log"), - ) { - Ok(child) => child, - Err(error) => { - eprintln!("Mint start failed before child ownership: {error:#}"); - return fail_before_evidence(&state_path, &mut state, FailureKind::MintStart); - } - }, - ); - publish_test_service_pid( - "mint", - children.mint.as_ref().expect("Mint child was assigned"), - )?; - if wait_for_http( - &format!("{}/.well-known/jwks.json", state.mint_origin), - children.mint.as_mut().expect("Mint child was assigned"), - HttpProof::MintEs256Key, - args.ready_timeout_seconds, - terminate, - ) - .is_err() + let pin = registry_thunderid_tooling::version::ThunderIdPin::load()?; + let issuer_label = format!("evidence-dev-{}", &state.issuer_session_id[..12]); + let issuer_root = dev_root.join("generated/issuer"); + let issuer_session = registry_thunderid_tooling::container::Session { + label: &issuer_label, + id: &state.issuer_session_id, + port: local_origin_port(&state.issuer_origin).expect("closed issuer origin"), + state_root: &issuer_root, + image: &pin.image, + }; + if let Err(error) = + registry_thunderid_tooling::local::start(&issuer_session, &args.docker_bin, &mut || { + terminate.load(Ordering::Relaxed) + }) { - eprintln!("Mint did not reach its fixed local JWKS readiness proof"); - return fail_before_evidence(&state_path, &mut state, FailureKind::MintReadiness); + eprintln!("Issuer start failed: {error}"); + return fail_before_evidence(&state_path, &mut state, FailureKind::IssuerStart); } + let mut children = OwnedChildren::default(); + children.evidence = Some( match spawn_evidence( &args.evidence_bin, @@ -1726,6 +1839,7 @@ fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { Ok(child) => child, Err(error) => { eprintln!("Evidence start failed: {error:#}"); + let _ = registry_thunderid_tooling::local::stop(&issuer_session, &args.docker_bin); return fail_before_evidence(&state_path, &mut state, FailureKind::EvidenceStart); } }, @@ -1750,6 +1864,7 @@ fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { .is_err() { eprintln!("Evidence did not reach its fixed local readiness proof"); + let _ = registry_thunderid_tooling::local::stop(&issuer_session, &args.docker_bin); return fail_before_evidence(&state_path, &mut state, FailureKind::EvidenceReadiness); } @@ -1762,14 +1877,16 @@ fn supervise(args: SupervisorArgs, terminate: &AtomicBool) -> Result<()> { .evidence .as_mut() .expect("Evidence child was assigned"), - children.mint.as_mut().expect("Mint child was assigned"), terminate, ) .unwrap_or(SupervisorOutcome::Failed(FailureKind::Supervisor)); state.status = DevStatus::Stopping; let stopping_state = replace_state(&state_path, &state); children.stop(); + let issuer_cleanup = registry_thunderid_tooling::local::stop(&issuer_session, &args.docker_bin) + .map_err(anyhow::Error::from); stopping_state?; + issuer_cleanup?; match outcome { SupervisorOutcome::Stop(mut stream) => { @@ -1806,14 +1923,13 @@ enum SupervisorOutcome { fn supervisor_loop( listener: &UnixListener, evidence: &mut Child, - mint: &mut Child, terminate: &AtomicBool, ) -> Result { loop { if terminate.load(Ordering::Relaxed) { return Ok(SupervisorOutcome::Failed(FailureKind::SupervisorSignal)); } - if evidence.try_wait()?.is_some() || mint.try_wait()?.is_some() { + if evidence.try_wait()?.is_some() { return Ok(SupervisorOutcome::Failed(FailureKind::ChildExited)); } match listener.accept() { @@ -1824,11 +1940,6 @@ fn supervisor_loop( if request == b"stop\n" { return Ok(SupervisorOutcome::Stop(stream)); } - if request == b"reload-mint\n" { - signal_child_with(mint, rustix::process::Signal::HUP)?; - stream.write_all(b"reload-requested\n")?; - continue; - } let _ = stream.write_all(b"invalid\n"); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { @@ -1839,19 +1950,6 @@ fn supervisor_loop( } } -fn spawn_service(binary: &Path, prefix: &[&str], value: &Path, log: &Path) -> Result { - let stdout = create_private_file(log)?; - let stderr = stdout.try_clone()?; - Command::new(binary) - .args(prefix) - .arg(value) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn() - .with_context(|| format!("failed to start {}", binary.display())) -} - fn spawn_evidence(binary: &Path, runtime: &Path, log: &Path) -> Result { let stdout = create_private_file(log)?; let stderr = stdout.try_clone()?; @@ -1867,7 +1965,6 @@ fn spawn_evidence(binary: &Path, runtime: &Path, log: &Path) -> Result { } enum HttpProof { - MintEs256Key, EvidenceReady, } @@ -1899,14 +1996,6 @@ fn wait_for_http( if bytes.len() as u64 <= MAX_HTTP_BODY_BYTES { let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); let matches = match proof { - HttpProof::MintEs256Key => value["keys"].as_array().is_some_and(|keys| { - keys.iter().any(|key| { - key["kty"] == "EC" - && key["crv"] == "P-256" - && key["alg"] == "ES256" - && key["kid"].as_str().is_some_and(|kid| kid.len() == 43) - }) - }), HttpProof::EvidenceReady => value == json!({"status": "ready"}), }; if matches && child.try_wait()?.is_none() { @@ -1926,24 +2015,17 @@ fn ensure_supervisor_active(terminate: &AtomicBool) -> Result<()> { Ok(()) } -fn stop_children(evidence: Option<&mut Child>, mint: Option<&mut Child>) { +fn stop_child(evidence: Option<&mut Child>) { let mut evidence = evidence; - let mut mint = mint; if let Some(child) = evidence.as_deref_mut() { let _ = signal_child(child); } - if let Some(child) = mint.as_deref_mut() { - let _ = signal_child(child); - } let deadline = Instant::now() + Duration::from_secs(SHUTDOWN_TIMEOUT_SECONDS); loop { let evidence_done = evidence .as_deref_mut() .is_none_or(|child| child.try_wait().ok().flatten().is_some()); - let mint_done = mint - .as_deref_mut() - .is_none_or(|child| child.try_wait().ok().flatten().is_some()); - if evidence_done && mint_done { + if evidence_done { return; } if Instant::now() >= deadline { @@ -1951,7 +2033,7 @@ fn stop_children(evidence: Option<&mut Child>, mint: Option<&mut Child>) { } thread::sleep(Duration::from_millis(50)); } - for child in [evidence, mint].into_iter().flatten() { + for child in [evidence].into_iter().flatten() { // A child that ignores TERM is killed only after the bounded graceful // deadline. Child::kill is SIGKILL on Unix and cannot run child cleanup. let _ = child.kill(); @@ -2010,13 +2092,10 @@ fn wait_for_supervisor_ready( /// reader in this file rather than in the session that just failed. fn startup_failure_summary(failure: Option, ports: LocalServicePorts) -> String { let evidence = ports.evidence; - let mint = ports.mint; + let issuer = ports.issuer; match failure { - Some(FailureKind::MintStart) => { - format!("the local Registry Mint service could not be started on 127.0.0.1:{mint}") - } - Some(FailureKind::MintReadiness) => { - format!("the local Registry Mint service did not become ready on 127.0.0.1:{mint}") + Some(FailureKind::IssuerStart) => { + format!("the local issuer could not be started on 127.0.0.1:{issuer}") } Some(FailureKind::EvidenceStart) => { format!( @@ -2029,7 +2108,7 @@ fn startup_failure_summary(failure: Option, ports: LocalServicePort ) } Some(FailureKind::ChildExited) => { - "a local service exited before the session was ready".to_owned() + "the local Evidence service exited after readiness".to_owned() } Some(FailureKind::Supervisor) => { "the local supervisor failed before the services were ready".to_owned() @@ -2054,87 +2133,6 @@ fn abort_start(supervisor: &mut Child) -> Result<()> { Ok(()) } -fn mint_config( - compiled: &CompiledProject, - mint_public: &Path, - secret_root: &Path, - ports: LocalServicePorts, -) -> Value { - let mint_origin = ports.mint_origin(); - let token_url = format!("{mint_origin}/token"); - json!({ - "version": 1, - "validationMode": "supervised-local-development", - "issuer": mint_origin, - "listener": { - "address": "127.0.0.1", - "port": ports.mint, - "maximumRequestBytes": 16384, - "requestTimeoutMilliseconds": 5000, - }, - "signing": { - "algorithm": "ES256", - "activePublicJwkFile": mint_public, - "publishedPublicJwkFiles": [], - "revokedKeyIds": [], - "jwksPath": "/.well-known/jwks.json", - }, - "signer": { - "kind": "local-jwk", - "privateKeyRef": "secret:file/mint-private.jwk", - }, - "secretProviders": {"file": {"root": secret_root}}, - "audit": { - "path": "audit/mint.jsonl", - // Mint rotates a sealed segment at this threshold. A local - // tutorial session never reaches it, and the value matches the - // documented deployment example. - "maximumFileBytes": 1_073_741_824u64, - "hashKeyRef": "secret:file/mint-audit-hmac-key", - "hashKeyVersion": 1, - }, - "accessTokens": { - "audiences": [compiled.local_audience], - "lifetimeSeconds": 300, - "claims": { - "principal": "sub", - "requesterTags": "evidence_tags", - "evidenceAudience": "evidence_audience", - "grantId": "evidence_grant_id", - "grantAuthority": "evidence_authority", - }, - }, - "clientAssertion": { - "audience": token_url, - "maximumLifetimeSeconds": 120, - "algorithms": ["ES256"], - "replayCacheEntries": 256, - }, - "clients": {"directory": "clients"}, - }) -} - -fn generate_mint_audit_key(path: &Path) -> Result<()> { - let mut entropy = Zeroizing::new([0_u8; 32]); - getrandom::fill(entropy.as_mut_slice()) - .context("failed to generate local Mint audit key material")?; - let key = Zeroizing::new(URL_SAFE_NO_PAD.encode(entropy.as_slice())); - let mut file = create_private_file(path)?; - file.write_all(key.as_bytes())?; - file.sync_all()?; - Ok(()) -} - -fn local_caller_registration(compiled: &CompiledProject, caller_public: Value) -> Value { - json!({ - "clientId": CALLER_ID, - "principal": "urn:registrystack:evidence:local:caller", - "evidenceAudience": compiled.caller_evidence_audience, - "requesterTags": [compiled.requester_tag], - "keys": [caller_public], - }) -} - impl From<&CompiledAccessPolicy> for AccessPolicyState { fn from(compiled: &CompiledAccessPolicy) -> Self { Self { @@ -2177,21 +2175,6 @@ impl From<&CompiledQuestion> for QuestionState { } } -fn run_check(binary: &Path, prefix: &[&str], config: &Path, name: &str) -> Result<()> { - let status = Command::new(binary) - .args(prefix) - .arg(config) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .with_context(|| format!("failed to run {name} check"))?; - if !status.success() { - bail!("{name} rejected the generated local configuration"); - } - Ok(()) -} - #[allow(dead_code)] // Shared by the immediately following request and audit slices. pub(crate) fn resolve_tool_binary( name: &str, @@ -2218,6 +2201,35 @@ fn canonical_tool_binary(path: PathBuf) -> Result { .with_context(|| format!("failed to resolve tool binary {}", path.display())) } +/// Resolve an argv-dispatching executable without replacing its final symlink. +/// Docker Desktop and OrbStack select the command from argv[0], so canonicalizing +/// `docker` into their shared `docker-tools` target changes the program invoked. +fn command_binary(path: PathBuf) -> Result { + let candidate = if path.components().count() == 1 { + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(&path)) + .find(|candidate| candidate.is_file()) + .ok_or_else(|| anyhow!("failed to resolve tool binary {}", path.display()))? + } else if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + let parent = candidate + .parent() + .ok_or_else(|| anyhow!("tool binary has no parent"))?; + let name = candidate + .file_name() + .ok_or_else(|| anyhow!("tool binary has no file name"))?; + let candidate = fs::canonicalize(parent)?.join(name); + let metadata = fs::metadata(&candidate) + .with_context(|| format!("failed to resolve tool binary {}", candidate.display()))?; + if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 { + bail!("tool binary is not an executable file"); + } + Ok(candidate) +} + fn canonical_project(path: &Path) -> Result { let canonical = fs::canonicalize(path) .with_context(|| format!("project {} is unavailable", path.display()))?; @@ -2362,17 +2374,6 @@ fn create_private_file(path: &Path) -> Result { Ok(file) } -fn write_private_yaml(path: &Path, value: &Value) -> Result<()> { - let mut text = serde_norway::to_string(value)?; - if !text.ends_with('\n') { - text.push('\n'); - } - let mut file = create_private_file(path)?; - file.write_all(text.as_bytes())?; - file.sync_all()?; - Ok(()) -} - fn read_owner_json(path: &Path, maximum: u64) -> Result { let bytes = read_owner_file(path, maximum)?; serde_json::from_slice(&bytes).context("owner-only JSON is invalid") @@ -2423,12 +2424,39 @@ fn replace_state(path: &Path, state: &DevState) -> Result<()> { Ok(()) } +fn replace_private_file(path: &Path, bytes: &[u8]) -> Result<()> { + if path.exists() { + require_owned_regular_file(path, PRIVATE_FILE_MODE)?; + } + let parent = path + .parent() + .ok_or_else(|| anyhow!("private output has no parent"))?; + validate_private_directory(parent)?; + let mut random = [0_u8; 9]; + getrandom::fill(&mut random)?; + let temporary = parent.join(format!(".output-{}", URL_SAFE_NO_PAD.encode(random))); + let mut file = create_private_file(&temporary)?; + file.write_all(bytes)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + Ok(()) +} + fn read_state(path: &Path) -> Result { let bytes = read_owner_file(path, MAX_STATE_BYTES)?; - let state: DevState = serde_json::from_slice(&bytes).context("local state is invalid")?; - if state.schema != STATE_SCHEMA { + let shape: Value = serde_json::from_slice(&bytes).context("local state is invalid")?; + if shape.get("schema").and_then(Value::as_str) != Some(STATE_SCHEMA) { + if shape.get("mintOrigin").is_some() + || shape + .get("schema") + .and_then(Value::as_str) + .is_some_and(|schema| schema.starts_with("registry.evidencectl.dev-state/v")) + { + bail!("this retained local session uses the retired Mint lifecycle; stop it with its matching Mint-era evidencectl, then start a fresh issuer-backed session. Nothing was changed"); + } bail!("local state schema is unsupported"); } + let state: DevState = serde_json::from_slice(&bytes).context("local state is invalid")?; Ok(state) } @@ -2534,6 +2562,7 @@ fn ready_question(question: QuestionState) -> ReadyQuestionState { #[cfg(test)] mod tests { use super::*; + use crate::authoring::CompiledProject; fn compiled(runtime: &Path) -> CompiledProject { CompiledProject { @@ -2557,7 +2586,7 @@ mod tests { concept_form: CompiledConceptForm::Boolean, }], }], - local_audience: "registry-evidence-local".to_owned(), + local_audience: "urn:registrystack:evidence:local:gateway".to_owned(), requester_tag: "local-caller".to_owned(), caller_evidence_audience: LOCAL_CALLER_EVIDENCE_AUDIENCE.to_owned(), access_policies: Vec::new(), @@ -2576,73 +2605,18 @@ mod tests { } #[test] - fn mint_documents_are_closed_and_derive_authority_from_the_compiler() { - let compiled = compiled(Path::new("/private/runtime.yaml")); - let config = mint_config( - &compiled, - Path::new("/private/mint-public.jwk.json"), - Path::new("/private"), - LocalServicePorts::default(), - ); - let caller = local_caller_registration( - &compiled, - json!({"kty":"EC","crv":"P-256","kid":"caller","alg":"ES256","x":"public","y":"public"}), - ); - assert_eq!(config["validationMode"], "supervised-local-development"); - assert_eq!(config["issuer"], "http://127.0.0.1:8081"); - assert_eq!( - config["listener"], - json!({ - "address": "127.0.0.1", "port": 8081, - "maximumRequestBytes": 16384, "requestTimeoutMilliseconds": 5000 - }) - ); - assert_eq!( - config["accessTokens"]["audiences"], - json!([compiled.local_audience]) - ); - assert_eq!( - config["audit"], - json!({ - "path": "audit/mint.jsonl", - "maximumFileBytes": 1_073_741_824u64, - "hashKeyRef": "secret:file/mint-audit-hmac-key", - "hashKeyVersion": 1, - }) - ); - assert_eq!(caller["requesterTags"], json!([compiled.requester_tag])); - assert_eq!( - caller["evidenceAudience"], - compiled.caller_evidence_audience - ); - assert!(caller.to_string().find("private").is_none()); - } - - #[test] - fn supervised_dev_generates_create_only_private_p256_mint_and_holder_pairs() { + fn supervised_dev_generates_create_only_private_p256_holder_pair() { let root = tempfile::tempdir().expect("tempdir"); let keys = root.path().join("keys"); - let mint_public = generate_service_and_holder_keys(&keys).expect("generate dev keys"); + generate_holder_key(&keys).expect("generate dev key"); - for name in ["mint", "holder"] { + for name in ["holder"] { let private_path = keys.join(format!("{name}-private.jwk")); let private = registry_platform_crypto::PrivateJwk::parse( &fs::read_to_string(&private_path).expect("private JWK"), ) .expect("private JWK parses"); - let public_path = if name == "mint" { - assert_eq!( - mint_public.file_name().and_then(|value| value.to_str()), - private - .kid - .as_deref() - .map(|kid| format!("{kid}.jwk.json")) - .as_deref() - ); - mint_public.clone() - } else { - keys.join("holder-public.jwk.json") - }; + let public_path = keys.join("holder-public.jwk.json"); let public = registry_platform_crypto::PublicJwk::parse( &fs::read_to_string(&public_path).expect("public JWK"), ) @@ -2668,10 +2642,8 @@ mod tests { PRIVATE_FILE_MODE ); } - assert!(!keys.join("mint-public.jwk.json").exists()); - let before = fs::read(keys.join("holder-private.jwk")).expect("holder private JWK"); - assert!(generate_service_and_holder_keys(&keys).is_err()); + assert!(generate_holder_key(&keys).is_err()); assert_eq!( fs::read(keys.join("holder-private.jwk")).expect("holder private JWK"), before, @@ -2799,13 +2771,14 @@ requirements: project: project.clone(), runtime_path: runtime.clone(), evidence_origin: local_origin(8080), - mint_origin: local_origin(8081), - token_url: format!("{}/token", local_origin(8081)), + issuer_origin: local_origin(8081), + issuer_session_id: "0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), + token_url: format!("{}/oauth2/token", local_origin(8081)), access_token_audience: compiled.local_audience.clone(), caller: Some(CallerState { client_id: CALLER_ID.to_owned(), private_key_path: caller_key, - assertion_audience: format!("{}/token", local_origin(8081)), + assertion_audience: local_origin(8081), evidence_audience: compiled.caller_evidence_audience.clone(), requester_tag: compiled.requester_tag.clone(), }), @@ -2930,7 +2903,7 @@ requirements: } #[test] - fn supervisor_reload_control_signals_only_mint_and_keeps_serving() { + fn supervisor_stop_control_keeps_evidence_owned_until_cleanup() { let temporary = tempfile::tempdir().expect("tempdir"); let socket = temporary.path().join("control.sock"); let listener = UnixListener::bind(&socket).expect("control listener"); @@ -2938,41 +2911,15 @@ requirements: .set_nonblocking(true) .expect("nonblocking listener"); - let mint_script = temporary.path().join("mint-child"); - let mint_ready = temporary.path().join("mint-ready"); - fs::write( - &mint_script, - "#!/bin/sh\ntrap ':' HUP\nprintf ready > \"$MINT_READY\"\nwhile :; do sleep 1; done\n", - ) - .expect("mint script"); - fs::set_permissions(&mint_script, fs::Permissions::from_mode(0o700)).expect("script mode"); - let mut mint = Command::new(&mint_script) - .env("MINT_READY", &mint_ready) - .spawn() - .expect("mint child"); let mut evidence = Command::new("/bin/sleep") .arg("30") .spawn() .expect("evidence child"); - let deadline = Instant::now() + Duration::from_secs(10); - while !mint_ready.is_file() { - if let Some(status) = mint.try_wait().expect("mint child status") { - panic!("mint child exited before installing its HUP handler: {status}"); - } - assert!( - Instant::now() < deadline, - "mint child did not install its HUP handler within 10 seconds" - ); - thread::sleep(Duration::from_millis(10)); - } - let client_socket = socket.clone(); let client = thread::spawn(move || { - send_control_request(&client_socket, b"reload-mint\n", b"reload-requested\n") - .expect("reload response"); send_control_request(&client_socket, b"stop\n", b"stopped\n").expect("stop response"); }); - let outcome = supervisor_loop(&listener, &mut evidence, &mut mint, &AtomicBool::new(false)) + let outcome = supervisor_loop(&listener, &mut evidence, &AtomicBool::new(false)) .expect("supervisor loop"); match outcome { SupervisorOutcome::Stop(mut stream) => { @@ -2982,11 +2929,8 @@ requirements: } client.join().expect("control client"); assert!(evidence.try_wait().expect("evidence status").is_none()); - assert!(mint.try_wait().expect("mint status").is_none()); signal_child(&evidence).expect("stop evidence"); - signal_child(&mint).expect("stop mint"); evidence.wait().expect("wait evidence"); - mint.wait().expect("wait mint"); } #[test] @@ -3016,11 +2960,10 @@ requirements: .take(16) .read_to_end(&mut request) .expect("request"); - assert_eq!(request, b"reload-mint\n"); - stream.write_all(b"reload-requested\n").expect("response"); + assert_eq!(request, b"status\n"); + stream.write_all(b"ready\n").expect("response"); }); - send_control_request(&long_socket, b"reload-mint\n", b"reload-requested\n") - .expect("long control path"); + send_control_request(&long_socket, b"status\n", b"ready\n").expect("long control path"); server.join().expect("server"); } @@ -3028,8 +2971,7 @@ requirements: fn every_startup_failure_reads_as_a_sentence_about_this_session() { let ports = LocalServicePorts::new(18080, 18081).expect("distinct ports"); let recorded = [ - Some(FailureKind::MintStart), - Some(FailureKind::MintReadiness), + Some(FailureKind::IssuerStart), Some(FailureKind::EvidenceStart), Some(FailureKind::EvidenceReadiness), Some(FailureKind::ChildExited), @@ -3047,9 +2989,8 @@ requirements: // The port is the fact the operator can act on, so a failure that // belongs to one service names that service's own port. assert!(summaries[0].contains("127.0.0.1:18081"), "{}", summaries[0]); - assert!(summaries[1].contains("127.0.0.1:18081"), "{}", summaries[1]); + assert!(summaries[1].contains("127.0.0.1:18080"), "{}", summaries[1]); assert!(summaries[2].contains("127.0.0.1:18080"), "{}", summaries[2]); - assert!(summaries[3].contains("127.0.0.1:18080"), "{}", summaries[3]); } #[test] @@ -3068,15 +3009,35 @@ requirements: assert!(error.contains("--evidence-port"), "{error}"); let error = probe_local_ports(LocalServicePorts::new(free_port, port).expect("ports")) - .expect_err("a held Mint port is refused") + .expect_err("a held issuer port is refused") .to_string(); - assert!(error.contains("--mint-port"), "{error}"); + assert!(error.contains("--issuer-port"), "{error}"); drop(held); probe_local_ports(LocalServicePorts::new(port, free_port).expect("ports")) .expect("a released port passes the probe"); } + #[test] + fn argv_dispatching_tool_keeps_its_final_symlink_name() { + let temporary = tempfile::tempdir().unwrap(); + let target = temporary.path().join("docker-tools"); + fs::write(&target, "#!/bin/sh\nexit 0\n").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).unwrap(); + let docker = temporary.path().join("docker"); + symlink("docker-tools", &docker).unwrap(); + + let resolved = command_binary(docker).unwrap(); + assert_eq!( + resolved.file_name().and_then(|name| name.to_str()), + Some("docker") + ); + assert!(fs::symlink_metadata(resolved) + .unwrap() + .file_type() + .is_symlink()); + } + #[test] fn stop_dev_reports_a_friendly_refusal_when_no_generated_root_exists() { let project = tempfile::tempdir().expect("tempdir"); diff --git a/crates/registry-evidencectl/src/lib.rs b/crates/registry-evidencectl/src/lib.rs index 969e7fea71..68177c65e4 100644 --- a/crates/registry-evidencectl/src/lib.rs +++ b/crates/registry-evidencectl/src/lib.rs @@ -94,7 +94,7 @@ enum Command { /// Compatibility operations over deployment artifacts. #[command(subcommand)] Artifact(ArtifactCommand), - /// Run the private local Registry Mint and Evidence Gateway pair. + /// Run the private local issuer and Evidence Gateway pair. Dev(dev::DevArgs), /// Prepare a closed request for the active local project. #[command(subcommand)] @@ -620,6 +620,34 @@ fn safe_dev_command(result: anyhow::Result) -> anyhow::Result() + .is_some() + { + return Err(SafeCliFailure { + operational: false, + code: "evidence.dev.mint-retired", + artifact: "local development command".to_owned(), + path: "$".to_owned(), + message: "Registry Mint development flags were removed.".to_owned(), + suggested_action: "Stop any retained Mint session with its matching older evidencectl, then start a fresh session with --issuer-port and the pinned local issuer.".to_owned(), + } + .into()); + } + if error + .downcast_ref::() + .is_some() + { + return Err(SafeCliFailure { + operational: false, + code: "evidence.dev.grant-authority-required", + artifact: "task grant".to_owned(), + path: "$".to_owned(), + message: "evidencectl does not issue or forge task grants.".to_owned(), + suggested_action: "Approve the task through the configured Casework authority, then exchange its signed assertion at the configured issuer.".to_owned(), + } + .into()); + } safe_command( Err(error), "evidence.dev.failed", @@ -780,10 +808,18 @@ mod tests { "project", "--evidence-port", "18080", - "--mint-port", + "--issuer-port", "18081", ]) .is_ok()); + assert!(Cli::try_parse_from([ + "evidencectl", + "dev", + "token", + "local-tutorial-caller", + "project" + ]) + .is_ok()); assert!(Cli::try_parse_from(["evidencectl", "dev", "--detach"]).is_ok()); assert!(Cli::try_parse_from(["evidencectl", "dev", "stop"]).is_ok()); assert!( diff --git a/crates/registry-evidencectl/src/request.rs b/crates/registry-evidencectl/src/request.rs index bad3b16c34..f6c276ae9f 100644 --- a/crates/registry-evidencectl/src/request.rs +++ b/crates/registry-evidencectl/src/request.rs @@ -3,7 +3,8 @@ //! The Evidence runtime supplies trusted local relying-procedure metadata and //! exact pinned subject bindings without making an authorization decision. The //! relying-party client owns the request, nonce, and retained verification -//! context. Mint separately supplies the bearer used by the tutorial's curl. +//! context. The retained local issuer separately supplies the bearer used by +//! the tutorial's curl. use std::{ collections::BTreeMap, @@ -39,7 +40,6 @@ const PRIVATE_FILE_MODE: u32 = 0o600; // that the profile legitimately permits. const MAX_SELECTOR_VALUE_BYTES: usize = 8 * 1024; const MAX_SUBJECTS_FILE_BYTES: u64 = 16 * 1024; -const MAX_TOKEN_BYTES: usize = 64 * 1024; const MAX_CONTEXT_BYTES: u64 = 256 * 1024; const LOCAL_PROCEDURE_INPUT_SCHEMA_V1: &str = "registry.evidence.local-relying-procedure-input/v1"; const LOCAL_PROCEDURE_SCHEMA_V1: &str = "registry.evidence.local-relying-procedure/v1"; @@ -489,6 +489,9 @@ fn progressive_curl_config( } fn prepare_local(args: PrepareArgs) -> Result { + if args.mint_bin.is_some() { + bail!("--mint-bin was removed with the local Registry Mint lifecycle; the ready dev session owns its pinned issuer") + } validate_request_name(&args.name)?; let ready = dev::load_ready_state(&args.project)?; let (question, subjects) = validate_closed_inputs(&ready, &args)?; @@ -498,11 +501,6 @@ fn prepare_local(args: PrepareArgs) -> Result { args.evidence_bin.as_deref(), "EVIDENCECTL_TEST_EVIDENCE_BIN", )?; - let mint = dev::resolve_tool_binary( - "mint", - args.mint_bin.as_deref(), - "EVIDENCECTL_TEST_MINT_BIN", - )?; let requests_root = ensure_requests_root(&ready.project)?; let destination = requests_root.join(&args.name); @@ -521,16 +519,10 @@ fn prepare_local(args: PrepareArgs) -> Result { .context("failed to remove the private relying procedure input")?; validate_local_relying_procedure(&procedure, question, &client.evidence_audience, args.format)?; - let token = obtain_token( - &mint, - &ready.token_url, - &client.client_id, - &client.private_key_path, - &client.assertion_audience, - )?; + let token = dev::obtain_issuer_token(&ready, &client.client_id, &client.private_key_path)?; let token_provider = Arc::new( StaticToken::new(token.as_str().to_owned()) - .context("Registry Mint returned an unusable token")?, + .context("the local issuer returned an unusable token")?, ); let evidence_origin = url::Url::parse(&ready.evidence_origin).context("the active Evidence origin is invalid")?; @@ -584,7 +576,6 @@ fn prepare_local(args: PrepareArgs) -> Result { struct RequestClient { client_id: String, private_key_path: PathBuf, - assertion_audience: String, evidence_audience: String, } @@ -603,7 +594,6 @@ fn resolve_request_client(ready: &ReadyDevState, client_id: Option<&str>) -> Res Ok(RequestClient { client_id: client.client_id, private_key_path: client.private_key_path, - assertion_audience: ready.token_url.clone(), evidence_audience: client.evidence_audience, }) } @@ -614,7 +604,6 @@ fn resolve_request_client(ready: &ReadyDevState, client_id: Option<&str>) -> Res Ok(RequestClient { client_id: caller.client_id.clone(), private_key_path: caller.private_key_path.clone(), - assertion_audience: caller.assertion_audience.clone(), evidence_audience: caller.evidence_audience.clone(), }) } @@ -918,67 +907,6 @@ fn evidence_request_spec( } } -fn obtain_token( - mint: &Path, - token_url: &str, - client_id: &str, - private_key_path: &Path, - assertion_audience: &str, -) -> Result> { - let mut child = Command::new(mint) - .arg("token") - .arg("--url") - .arg(token_url) - .arg("--client-id") - .arg(client_id) - .arg("--key") - .arg(private_key_path) - .arg("--audience") - .arg(assertion_audience) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .context("failed to invoke Mint")?; - let mut stdout = Zeroizing::new(Vec::with_capacity(MAX_TOKEN_BYTES + 2)); - let read_result = child - .stdout - .take() - .ok_or_else(|| anyhow!("failed to open Mint token output"))? - .take((MAX_TOKEN_BYTES + 3) as u64) - .read_to_end(&mut stdout); - if read_result.is_err() || stdout.len() > MAX_TOKEN_BYTES + 2 { - let _ = child.kill(); - let _ = child.wait(); - bail!("Registry Mint refused a token for client {client_id}"); - } - let status = child.wait().context("failed to wait for Mint")?; - if !status.success() { - bail!("Registry Mint refused a token for client {client_id}"); - } - if std::str::from_utf8(&stdout).is_err() { - bail!("Registry Mint refused a token for client {client_id}"); - } - let mut token = Zeroizing::new( - String::from_utf8(std::mem::take(&mut stdout)).expect("Mint output was validated as UTF-8"), - ); - if token.ends_with('\n') { - token.pop(); - if token.ends_with('\r') { - token.pop(); - } - } - if token.is_empty() - || token.len() > MAX_TOKEN_BYTES - || token - .bytes() - .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))) - { - bail!("Registry Mint refused a token for client {client_id}"); - } - Ok(token) -} - fn write_authorization(path: &Path, token: &str) -> Result<()> { let mut contents = Zeroizing::new(String::with_capacity(token.len() + 36)); contents.push_str("header = \"Authorization: Bearer "); @@ -1208,8 +1136,8 @@ mod tests { project: PathBuf::from("/tmp/project"), runtime_path: PathBuf::from("/tmp/runtime.yaml"), evidence_origin: "http://127.0.0.1:8080".to_owned(), - mint_origin: "http://127.0.0.1:8081".to_owned(), - token_url: "http://127.0.0.1:8081/token".to_owned(), + issuer_origin: "http://127.0.0.1:8081".to_owned(), + token_url: "http://127.0.0.1:8081/oauth2/token".to_owned(), access_token_audience: "local".to_owned(), caller: None, access_policies: vec![], diff --git a/crates/registry-evidencectl/src/source_add.rs b/crates/registry-evidencectl/src/source_add.rs index bdc7f55764..389b071e47 100644 --- a/crates/registry-evidencectl/src/source_add.rs +++ b/crates/registry-evidencectl/src/source_add.rs @@ -117,6 +117,10 @@ struct Endpoints { breg_url: String, token_endpoint: String, audience: String, + #[serde(default)] + client_assertion_audience: Option, + #[serde(default)] + resource: Option, } struct Selection { @@ -248,8 +252,18 @@ fn configure( prepare.push("--all-records".into()); } let preview = invoke(&prepare)?; - validate_preparation(&preview, &selection, &inspection.endpoints)?; - let binding = connection(&inspection.endpoints, &args.connection); + let prepared_scope = prepared_client_scope(&preview)?; + validate_preparation( + &preview, + &selection, + &inspection.endpoints, + prepared_scope.as_deref(), + )?; + let binding = connection( + &inspection.endpoints, + prepared_scope.as_deref(), + &args.connection, + ); target::check_local_connection(&target_path, &args.connection, &binding)?; if project.exists() { check_credential_outputs( @@ -278,6 +292,15 @@ fn configure( "requiresRestart": preview["requiresRestart"], "recoveredPriorApply": inspection.recovered_prior_apply || preview["recoveredPriorApply"] == true, }); + if let Some(resource) = &inspection.endpoints.resource { + report["resource"] = json!(resource); + } + if let Some(audience) = &inspection.endpoints.client_assertion_audience { + report["clientAssertionAudience"] = json!(audience); + } + if let Some(scope) = &prepared_scope { + report["scope"] = json!(scope); + } if !args.apply { eprintln!( "Reviewed a {}.{} lookup for facts [{}], scope {}, and dedicated client {}. No new choices were applied.", @@ -316,7 +339,12 @@ fn configure( // Its identical retry contract preserves the same pending activation. prepare.push("--apply".into()); let prepared = invoke(&prepare)?; - validate_preparation(&prepared, &selection, &inspection.endpoints)?; + validate_preparation( + &prepared, + &selection, + &inspection.endpoints, + prepared_scope.as_deref(), + )?; let exported = tempfile::tempdir().context("staging the public source export")?; let export = fs::canonicalize(exported.path()) @@ -639,7 +667,12 @@ fn add_pair(arguments: &mut Vec, flag: &str, value: impl AsRef Result<()> { +fn validate_preparation( + report: &Value, + selected: &Selection, + expected: &Endpoints, + expected_scope: Option<&str>, +) -> Result<()> { let endpoints: Endpoints = serde_json::from_value(report.clone()) .map_err(|_| anyhow::anyhow!("BReg preparation omitted its connection endpoints"))?; if &endpoints != expected @@ -650,6 +683,7 @@ fn validate_preparation(report: &Value, selected: &Selection, expected: &Endpoin || report["accessProfile"] != selected.access_profile || report["client"] != selected.client || report["readableFields"] != json!(selected.fields) + || prepared_client_scope(report)?.as_deref() != expected_scope { bail!("BReg preparation differs from the reviewed source choices; inspect the registry before retrying"); } @@ -663,10 +697,37 @@ fn validate_preparation(report: &Value, selected: &Selection, expected: &Endpoin Ok(()) } +/// A selected BReg preparation reports the exact permission scope it will +/// register for this dedicated client. Older handoffs omit the member and +/// retain their existing scope-free source connection behavior. +fn prepared_client_scope(report: &Value) -> Result> { + let Some(scopes) = report.get("preparedClientScopes") else { + return Ok(None); + }; + let scopes: Vec = serde_json::from_value(scopes.clone()) + .map_err(|_| anyhow::anyhow!("BReg reported invalid prepared client scopes"))?; + if scopes.is_empty() + || scopes.len() > 16 + || scopes.iter().any(|scope| { + scope.is_empty() + || !scope + .bytes() + .all(|byte| (0x21..=0x7e).contains(&byte) && byte != b'"' && byte != b'\\') + }) + { + bail!("BReg reported invalid prepared client scopes"); + } + let joined = scopes.join(" "); + if joined.len() > 512 { + bail!("BReg reported invalid prepared client scopes"); + } + Ok(Some(joined)) +} + fn validate_endpoints(endpoints: &Endpoints) -> Result<()> { - for (value, path) in [ - (&endpoints.breg_url, "/"), - (&endpoints.token_endpoint, "/token"), + for (value, token_endpoint) in [ + (&endpoints.breg_url, false), + (&endpoints.token_endpoint, true), ] { let url = url::Url::parse(value).context("BReg reported an invalid local endpoint")?; let loopback = match url.host() { @@ -674,10 +735,15 @@ fn validate_endpoints(endpoints: &Endpoints) -> Result<()> { Some(url::Host::Ipv6(address)) => address.is_loopback(), _ => false, }; + let fixed_path = if token_endpoint { + matches!(url.path(), "/token" | "/oauth2/token") + } else { + url.path() == "/" + }; if url.scheme() != "http" || !loopback || url.port().is_none() - || url.path() != path + || !fixed_path || !url.username().is_empty() || url.password().is_some() || url.query().is_some() @@ -692,11 +758,57 @@ fn validate_endpoints(endpoints: &Endpoints) -> Result<()> { { bail!("BReg reported an invalid token audience"); } + if let Some(resource) = &endpoints.resource { + // Keep the standalone author's URI boundary aligned with Evidence's + // runtime check; Url alone normalizes bytes that must be sent intact. + if resource.is_empty() + || resource.len() > 512 + || !resource.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || b"-._~:/?[]@!$&'()*+,;=%#".contains(&byte) + }) + { + bail!("BReg reported an invalid OAuth resource"); + } + let parsed = + url::Url::parse(resource).context("BReg reported an invalid OAuth resource")?; + if parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + || matches!(parsed.scheme(), "http" | "https") && parsed.host().is_none() + { + bail!("BReg reported an invalid OAuth resource"); + } + } + if let Some(audience) = &endpoints.client_assertion_audience { + if audience.is_empty() + || audience.len() > 512 + || !audience.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) + { + bail!("BReg reported an invalid client assertion audience"); + } + let url = url::Url::parse(audience) + .context("BReg reported an invalid client assertion audience")?; + let token = url::Url::parse(&endpoints.token_endpoint) + .context("BReg reported an invalid local token endpoint")?; + if url.scheme() != "http" + || !matches!(url.host(), Some(url::Host::Ipv4(address)) if address.is_loopback()) + && !matches!(url.host(), Some(url::Host::Ipv6(address)) if address.is_loopback()) + || url.port().is_none() + || url.path() != "/" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.origin() != token.origin() + { + bail!("BReg reported an invalid client assertion audience"); + } + } Ok(()) } -fn connection(endpoints: &Endpoints, name: &str) -> Value { - json!({ +fn connection(endpoints: &Endpoints, scope: Option<&str>, name: &str) -> Value { + let mut binding = json!({ "baseUrl": endpoints.breg_url, "authentication": { "kind": "oauth2-client-credentials", "tokenEndpoint": endpoints.token_endpoint, @@ -705,7 +817,17 @@ fn connection(endpoints: &Endpoints, name: &str) -> Value { "audience": endpoints.audience, "maximumCacheSeconds": 60, }, "concurrencyLimit": 4, "admissionTimeoutMilliseconds": 5000, "tokenTimeoutMilliseconds": 5000, - }) + }); + if let Some(resource) = &endpoints.resource { + binding["authentication"]["resource"] = json!(resource); + } + if let Some(audience) = &endpoints.client_assertion_audience { + binding["authentication"]["clientAssertionAudience"] = json!(audience); + } + if let Some(scope) = scope { + binding["authentication"]["scope"] = json!(scope); + } + binding } fn plain_directory(path: &Path, what: &str) -> Result { @@ -961,7 +1083,8 @@ mod tests { self.calls.push(arguments.to_vec()); if arguments.iter().any(|arg| arg == "prepare-source") { let mut report = json!({"ok":true,"status":"inspect","requiresRestart":true, - "bregUrl":"http://127.0.0.1:19090","tokenEndpoint":"http://127.0.0.1:19091/token","audience":"urn:test:retained-session", + "bregUrl":"http://127.0.0.1:19090","tokenEndpoint":"http://127.0.0.1:19091/oauth2/token","audience":"urn:test:retained-session", + "clientAssertionAudience":"http://127.0.0.1:19091","resource":"urn:test:retained-session", "entities":[{"id":"record","selectorFields":[{"id":"code","type":"string"}], "readableFields":[{"id":"code","type":"string"},{"id":"name","type":"string"},{"id":"group","type":"string"}], "rowFields":[{"id":"group","type":"string"}]}]}); @@ -984,6 +1107,8 @@ mod tests { report["readableFields"] = json!(option(arguments, "--readable-fields") .split(',') .collect::>()); + report["preparedClientScopes"] = + json!([format!("registry:{}:lookup", option(arguments, "--client"))]); report["rowScope"] = if arguments.iter().any(|arg| arg == "--all-records") { json!({"kind":"all-records"}) } else { @@ -1106,6 +1231,9 @@ mod tests { json!({"registry-name":"record-code"}) ); assert_eq!(first["requiresRestart"], true); + assert_eq!(first["resource"], "urn:test:retained-session"); + assert_eq!(first["clientAssertionAudience"], "http://127.0.0.1:19091"); + assert_eq!(first["scope"], "registry:registry-name:lookup"); assert_eq!(first["target"], json!(project.join("targets/local"))); assert_eq!(fs::read_dir(project.join("questions")).unwrap().count(), 0); assert!(project.join("sources/registry-name.yaml").is_file()); @@ -1114,12 +1242,24 @@ mod tests { assert_eq!(connections["registry"]["baseUrl"], "http://127.0.0.1:19090"); assert_eq!( connections["registry"]["authentication"]["tokenEndpoint"], - "http://127.0.0.1:19091/token" + "http://127.0.0.1:19091/oauth2/token" ); assert_eq!( connections["registry"]["authentication"]["audience"], "urn:test:retained-session" ); + assert_eq!( + connections["registry"]["authentication"]["resource"], + "urn:test:retained-session" + ); + assert_eq!( + connections["registry"]["authentication"]["clientAssertionAudience"], + "http://127.0.0.1:19091" + ); + assert_eq!( + connections["registry"]["authentication"]["scope"], + "registry:registry-name:lookup" + ); let signing = fs::read(project.join("secrets/signing-p256-private-jwk")).unwrap(); let target = fs::read(project.join("targets/local/governance.yaml")).unwrap(); let second = configure(args(®istry, &project), false, &mut |a| { @@ -1513,10 +1653,108 @@ mod tests { breg_url: "https://provider.example".into(), token_endpoint: "http://127.0.0.1:9091/token".into(), audience: "urn:test".into(), + client_assertion_audience: None, + resource: None, }; assert!(validate_endpoints(&endpoints).is_err()); } + #[test] + fn older_source_handoff_keeps_optional_token_parameters_absent() { + let endpoints: Endpoints = serde_json::from_value(json!({ + "bregUrl": "http://127.0.0.1:19090/", + "tokenEndpoint": "http://127.0.0.1:19091/token", + "audience": "urn:legacy:registry" + })) + .unwrap(); + assert!(validate_endpoints(&endpoints).is_ok()); + assert_eq!(prepared_client_scope(&json!({})).unwrap(), None); + let authentication = connection(&endpoints, None, "registry")["authentication"].clone(); + assert!(authentication.get("resource").is_none()); + assert!(authentication.get("clientAssertionAudience").is_none()); + assert!(authentication.get("scope").is_none()); + assert_eq!(authentication["audience"], "urn:legacy:registry"); + } + + #[test] + fn malformed_prepared_client_scopes_are_refused_before_project_authoring() { + for scopes in [ + json!([]), + json!(["registry:one:lookup", "bad scope"]), + json!(["registry:one:lookup\nnext"]), + json!("registry:one:lookup"), + ] { + assert!(prepared_client_scope(&json!({"preparedClientScopes": scopes})).is_err()); + } + } + + #[test] + fn source_add_refuses_scope_drift_between_preview_and_prepared_reports() { + let root = tempfile::tempdir().unwrap(); + let registry = root.path().join("registry"); + fs::create_dir(®istry).unwrap(); + let project = root.path().join("evidence"); + let mut provider = Provider::new(); + let error = configure(args(®istry, &project), false, &mut |arguments| { + let mut report = provider.invoke(arguments)?; + if arguments.iter().any(|arg| arg == "prepare-source") + && arguments.iter().any(|arg| arg == "--apply") + { + report["preparedClientScopes"] = json!(["registry:different:lookup"]); + } + Ok(report) + }) + .unwrap_err(); + assert!(error + .to_string() + .contains("BReg preparation differs from the reviewed source choices")); + } + + #[test] + fn client_assertion_audience_must_be_the_exact_local_issuer_origin() { + let mut endpoints = Endpoints { + breg_url: "http://127.0.0.1:19090/".into(), + token_endpoint: "http://127.0.0.1:19091/token".into(), + audience: "urn:test:registry".into(), + client_assertion_audience: Some("http://127.0.0.1:19091".into()), + resource: None, + }; + assert!(validate_endpoints(&endpoints).is_ok()); + for invalid in [ + " http://127.0.0.1:19091", + "http://127.0.0.1:19091\n", + "http://127.0.0.1:19091/other", + "http://user@127.0.0.1:19091", + "http://127.0.0.1:19092", + "https://127.0.0.1:19091", + "http://127.0.0.1:19091/#fragment", + ] { + endpoints.client_assertion_audience = Some(invalid.into()); + assert!(validate_endpoints(&endpoints).is_err(), "{invalid}"); + } + } + + #[test] + fn reported_oauth_resource_must_be_an_exact_absolute_uri() { + let mut endpoints = Endpoints { + breg_url: "http://127.0.0.1:19090/".into(), + token_endpoint: "http://127.0.0.1:19091/oauth2/token".into(), + audience: "urn:test:registry".into(), + client_assertion_audience: None, + resource: Some("https://[::1]/records".into()), + }; + assert!(validate_endpoints(&endpoints).is_ok()); + for invalid in [ + "records", + "https://user@registry.invalid/records", + "https://registry.invalid/records#fragment", + "https://registry.invalid/records with space", + ] { + endpoints.resource = Some(invalid.into()); + assert!(validate_endpoints(&endpoints).is_err(), "{invalid}"); + } + } + #[test] fn source_identifier_defaults_avoid_teaching_profile_and_selector_names() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/registry-evidencectl/tests/audit_view.rs b/crates/registry-evidencectl/tests/audit_view.rs index 6bc581a104..ec5551d2f0 100644 --- a/crates/registry-evidencectl/tests/audit_view.rs +++ b/crates/registry-evidencectl/tests/audit_view.rs @@ -475,14 +475,15 @@ impl Fixture { private_file(&root.join(".evidence/dev/runtime.yaml"), b"runtime", 0o400); let canonical = fs::canonicalize(&root).expect("canonical project"); let state = json!({ - "schema": "registry.evidencectl.dev-state/v5", + "schema": "registry.evidencectl.dev-state/v6", "status": "stopped", "project": canonical, "runtimePath": canonical.join(".evidence/dev/runtime.yaml"), "evidenceOrigin": "http://127.0.0.1:8080", - "mintOrigin": "http://127.0.0.1:8081", - "tokenUrl": "http://127.0.0.1:8081/token", - "accessTokenAudience": "registry-evidence-local", + "issuerOrigin": "http://127.0.0.1:8081", + "issuerSessionId": "0123456789abcdef0123456789abcdef0123456789abcdef", + "tokenUrl": "http://127.0.0.1:8081/oauth2/token", + "accessTokenAudience": "urn:registrystack:evidence:local:gateway", "caller": null, "accessPolicies": [], "questions": [ diff --git a/crates/registry-evidencectl/tests/dev_lifecycle.rs b/crates/registry-evidencectl/tests/dev_lifecycle.rs index 1e8b2ff01e..c4d66b5bed 100644 --- a/crates/registry-evidencectl/tests/dev_lifecycle.rs +++ b/crates/registry-evidencectl/tests/dev_lifecycle.rs @@ -1,25 +1,19 @@ -//! Real first-tutorial lifecycle proof. +//! Focused local Evidence lifecycle proofs. //! -//! The test is ignored in the ordinary package suite because it owns the -//! fixed tutorial ports. The grouped lifecycle gate builds the sibling Mint -//! and Evidence binaries, supplies their paths, and runs this test exactly. +//! Ordinary tests stop at the process and filesystem boundary. The ignored +//! gate starts the pinned stock issuer container and the real Evidence binary, +//! acquires a token through the public CLI, and verifies its typed claims. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde_json::Value; use std::{ - ffi::OsStr, fs, net::TcpListener, - os::unix::{ - ffi::OsStrExt as _, - fs::{symlink, MetadataExt as _, PermissionsExt as _}, - }, + os::unix::fs::{symlink, MetadataExt as _, PermissionsExt as _}, path::{Path, PathBuf}, - process::{Child, Command, Output}, - thread, - time::{Duration, Instant}, + process::{Command, Output}, }; -use serde_json::{json, Value}; - const OPENAPI: &str = r#"openapi: 3.1.0 info: {title: Tutorial registry, version: 1.0.0} servers: [{url: 'http://127.0.0.1:8000'}] @@ -72,671 +66,125 @@ const DERIVATION: &str = r#"fn answer(facts, selectors, context) { } "#; -const AGE_BRACKET_QUESTION: &str = r#"id: age-bracket -question: Which age bracket does this person belong to? -purpose: service-path-selection -subject: - role: person - selector: person_id -source: - operation: getPerson - facts: - - name: date_of_birth - path: /date_of_birth - combine: exactly-one - collectionBounds: {} -answers: - - concept: age_bracket - type: controlled-category - values: [under-18, 18-to-24, 25-to-64, 65-or-older] -derivation: derivations/age-bracket.rhai -disclosure: - allow: [age_bracket] -"#; - -const AGE_BRACKET_DERIVATION: &str = r#"fn answer(facts, selectors, context) { - let born = parse_date(required(facts.date_of_birth, "date_of_birth_missing")); - if compare_dates(context.legal_local_date, add_calendar_years(born, 18)) < 0 { - #{age_bracket: "under-18"} - } else if compare_dates(context.legal_local_date, add_calendar_years(born, 25)) < 0 { - #{age_bracket: "18-to-24"} - } else if compare_dates(context.legal_local_date, add_calendar_years(born, 65)) < 0 { - #{age_bracket: "25-to-64"} - } else { - #{age_bracket: "65-or-older"} - } -} -"#; - #[test] -#[ignore = "exact gate: owns fixed 127.0.0.1:8080 and :8081 tutorial ports"] -fn real_detached_lifecycle_is_ready_private_and_stops_only_owned_children() { +#[ignore = "exact gate: starts the pinned stock issuer container and real Evidence binary"] +fn real_issuer_lifecycle_issues_typed_service_claims_and_stops_cleanly() { let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let fixture = Project::new_long_path(); - assert!( - fixture - .root - .join(".evidence/dev/control.sock") - .as_os_str() - .as_bytes() - .len() - > 104, - "test path must exceed the common sockaddr_un.sun_path limit" - ); - fixture.generate_evidence_keys(); - - let mut unrelated = Command::new("/bin/sleep") - .arg("60") - .spawn() - .expect("start unrelated process"); - - let started = fixture.dev_start(&evidence, &mint); - assert_success(&started, "dev --detach"); - let stdout = String::from_utf8_lossy(&started.stdout); - assert_eq!( - stdout, - "Evidence ready at http://127.0.0.1:8080\nMint ready at http://127.0.0.1:8081\n" - ); - assert!(ready( - "http://127.0.0.1:8080/ready", - json!({"status":"ready"}) - )); - assert!(jwks_ready()); - - let duplicate = fixture.dev_start(&evidence, &mint); - assert!(!duplicate.status.success(), "duplicate start must fail"); - assert!(ready( - "http://127.0.0.1:8080/ready", - json!({"status":"ready"}) - )); - - let dev = fixture.root.join(".evidence/dev"); - assert_mode(&fixture.root.join(".evidence"), 0o700); - assert_mode(&dev, 0o700); - assert_mode(&dev.join("generated/audit"), 0o700); - for path in [ - "state.json", - "generated/mint.yaml", - "generated/clients/caller.yaml", - "generated/audit/mint.jsonl", - "generated/keys/mint-audit-hmac-key", - "generated/keys/mint-private.jwk", - "generated/keys/holder-private.jwk", - "generated/keys/holder-public.jwk.json", - "generated/keys/caller-private.jwk", - "generated/keys/caller-public.jwk.json", - "logs/supervisor.log", - "logs/mint.log", - "logs/evidence.log", - ] { - assert_mode(&dev.join(path), 0o600); - } - let mint_private_path = dev.join("generated/keys/mint-private.jwk"); - let mint_private: Value = - serde_json::from_slice(&fs::read(&mint_private_path).expect("generated Mint private JWK")) - .expect("generated Mint private JWK parses"); - let mint_kid = mint_private["kid"] - .as_str() - .expect("generated Mint private JWK has a kid"); - let mint_public_path = dev.join(format!("generated/keys/{mint_kid}.jwk.json")); - assert_mode(&mint_public_path, 0o600); - for name in ["mint", "caller", "holder"] { - let private_path = dev.join(format!("generated/keys/{name}-private.jwk")); - let private: Value = if name == "mint" { - mint_private.clone() - } else { - serde_json::from_slice(&fs::read(private_path).expect("generated private JWK")) - .expect("generated private JWK parses") - }; - let public_path = if name == "mint" { - mint_public_path.clone() - } else { - dev.join(format!("generated/keys/{name}-public.jwk.json")) - }; - let public: Value = - serde_json::from_slice(&fs::read(public_path).expect("generated public JWK")) - .expect("generated public JWK parses"); - assert_eq!(private["kty"], "EC"); - assert_eq!(private["crv"], "P-256"); - assert_eq!(private["alg"], "ES256"); - assert_eq!(public["kty"], "EC"); - assert_eq!(public["crv"], "P-256"); - assert_eq!(public["alg"], "ES256"); - assert_eq!(private["kid"], public["kid"]); - } - - let state: Value = serde_json::from_slice(&fs::read(dev.join("state.json")).expect("state")) - .expect("state JSON"); - assert_eq!(state["schema"], "registry.evidencectl.dev-state/v5"); - assert_eq!(state["status"], "ready"); - assert_eq!(state["accessTokenAudience"], "registry-evidence-local"); - assert_eq!(state["caller"]["requesterTag"], "local-caller"); - assert_eq!(state["accessPolicies"], json!([])); - assert_eq!(state["questions"][0]["alias"], "adult-status"); - assert_eq!( - state["questions"][0]["requirementUri"], - "urn:registrystack:evidence:local:requirement:adult-status" - ); - let encoded_state = serde_json::to_string(&state).expect("state encodes"); - for prohibited in [ - "access_token", - "\"d\"", - "generations", - "receipt", - "template", - ] { - assert!( - !encoded_state.contains(prohibited), - "state contains {prohibited}" - ); - } - - assert_success( - &Command::new(&mint) - .args(["check", "--config"]) - .arg(dev.join("generated/mint.yaml")) - .output() - .expect("mint check"), - "mint check", - ); - assert_success( - &Command::new(&evidence) - .arg("--runtime") - .arg(dev.join("runtime.yaml")) - .arg("check") - .output() - .expect("evidence check"), - "evidence check", - ); - - let stopped = fixture.dev_stop(); - assert_success(&stopped, "dev stop"); - assert_eq!( - String::from_utf8_lossy(&stopped.stdout), - "Local Evidence stopped\n" - ); - wait_unavailable("127.0.0.1:8080"); - wait_unavailable("127.0.0.1:8081"); - assert!(unrelated.try_wait().expect("unrelated status").is_none()); - stop_child(&mut unrelated); - - let entries = sorted_names(&dev); - assert_eq!(entries, ["audit", "bundle", "runtime.yaml", "state.json"]); - let stopped_state: Value = - serde_json::from_slice(&fs::read(dev.join("state.json")).expect("stopped state")) - .expect("stopped state JSON"); - assert_eq!(stopped_state["status"], "stopped"); - assert!(stopped_state["caller"].is_null()); - assert!(dev.join("audit/evidence.jsonl").is_file()); - assert!(dev.join("runtime.yaml").is_file()); - assert_success(&fixture.dev_clean(), "dev clean"); - assert!(!dev.exists(), "clean removes the sealed stopped generation"); -} - -#[test] -#[ignore = "exact gate: starts real local Mint and Evidence services"] -fn configurable_ports_drive_every_generated_url_and_listener() { - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); + let docker = installed_or_env("DOCKER_BIN", "docker"); let fixture = Project::new(); fixture.generate_evidence_keys(); - let (evidence_port, mint_port) = unused_port_pair(); - - let started = fixture.dev_start_on_ports(&evidence, &mint, evidence_port, mint_port); - assert_success(&started, "dev --detach on configured ports"); - assert_eq!( - String::from_utf8_lossy(&started.stdout), - format!( - "Evidence ready at http://127.0.0.1:{evidence_port}\nMint ready at http://127.0.0.1:{mint_port}\n" - ) - ); - assert!(ready( - &format!("http://127.0.0.1:{evidence_port}/ready"), - json!({"status":"ready"}) - )); - assert!(jwks_ready_at(mint_port)); - - let dev = fixture.root.join(".evidence/dev"); - let state: Value = serde_json::from_slice(&fs::read(dev.join("state.json")).unwrap()).unwrap(); - assert_eq!( - state["evidenceOrigin"], - format!("http://127.0.0.1:{evidence_port}") - ); - assert_eq!( - state["tokenUrl"], - format!("http://127.0.0.1:{mint_port}/token") - ); - let runtime: Value = serde_norway::from_slice(&fs::read(dev.join("runtime.yaml")).unwrap()) - .expect("runtime YAML"); - let mint_config: Value = - serde_norway::from_slice(&fs::read(dev.join("generated/mint.yaml")).unwrap()) - .expect("Mint YAML"); - assert_eq!(runtime["listener"]["port"], evidence_port); - assert_eq!(mint_config["listener"]["port"], mint_port); - assert_eq!(mint_config["audit"]["path"], "audit/mint.jsonl"); - assert_eq!(mint_config["audit"]["hashKeyVersion"], 1); - assert_eq!( - mint_config["clientAssertion"]["audience"], - format!("http://127.0.0.1:{mint_port}/token") - ); - - assert_success(&fixture.dev_stop(), "stop configured ports"); - wait_unavailable(&format!("127.0.0.1:{evidence_port}")); - wait_unavailable(&format!("127.0.0.1:{mint_port}")); - - let held = TcpListener::bind(("127.0.0.1", evidence_port)).expect("hold Evidence port"); - let refused = fixture.dev_restart(&evidence, &mint); - assert_eq!(refused.status.code(), Some(3)); - assert!(String::from_utf8_lossy(&refused.stderr).contains(&evidence_port.to_string())); - let retained: Value = - serde_json::from_slice(&fs::read(dev.join("state.json")).unwrap()).unwrap(); - assert_eq!(retained["status"], "stopped"); - assert_eq!( - retained["evidenceOrigin"], - format!("http://127.0.0.1:{evidence_port}") - ); - assert_eq!( - retained["mintOrigin"], - format!("http://127.0.0.1:{mint_port}") - ); - drop(held); + let (evidence_port, issuer_port) = unused_port_pair(); - let interrupted = fixture.dev_start_with_env( - &evidence, - &mint, - "EVIDENCECTL_TEST_PARENT_EXIT_STAGE", - OsStr::new("after-dev-root"), - ); - assert_eq!(interrupted.status.code(), Some(86)); - let retained_root = fixture.root.join(".evidence/dev-stopped-before-restart"); - assert!(dev.is_dir()); - assert!(retained_root.is_dir()); - let interrupted_logs = dev.join("logs"); - fs::create_dir(&interrupted_logs).expect("interrupted preparation logs"); - fs::set_permissions(&interrupted_logs, fs::Permissions::from_mode(0o700)) - .expect("interrupted preparation log mode"); - let interrupted_log = interrupted_logs.join("supervisor.log"); - fs::write(&interrupted_log, "parent interrupted during preparation\n") - .expect("interrupted preparation diagnostic"); - fs::set_permissions(&interrupted_log, fs::Permissions::from_mode(0o600)) - .expect("interrupted preparation diagnostic mode"); - - let inactive = fixture.dev_stop(); + let started = fixture.dev_start(&evidence, &docker, evidence_port, issuer_port); + assert_success(&started, "issuer-backed dev start"); + let stdout = String::from_utf8_lossy(&started.stdout); assert!( - !inactive.status.success(), - "the restored session is stopped" - ); - assert!(!retained_root.exists()); - assert_eq!( - fs::read_to_string(fixture.root.join(".evidence/failed-start/supervisor.log")) - .expect("preserved interrupted preparation diagnostic"), - "parent interrupted during preparation\n" - ); - let recovered: Value = - serde_json::from_slice(&fs::read(dev.join("state.json")).unwrap()).unwrap(); - assert_eq!(recovered["status"], "stopped"); - assert_eq!( - recovered["evidenceOrigin"], - format!("http://127.0.0.1:{evidence_port}") - ); - assert_eq!( - recovered["mintOrigin"], - format!("http://127.0.0.1:{mint_port}") + stdout.contains(&format!( + "Evidence ready at http://127.0.0.1:{evidence_port}" + )), + "{stdout}" ); - - let failed = fixture.dev_start_with_env( - &evidence, - &mint, - "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", - OsStr::new("before-socket"), - ); - assert_eq!(failed.status.code(), Some(3)); - let failed_logs = fixture.root.join(".evidence/failed-start/supervisor.log"); assert!( - failed_logs.is_file(), - "failed restart keeps its diagnostics" - ); - let retained: Value = - serde_json::from_slice(&fs::read(dev.join("state.json")).unwrap()).unwrap(); - assert_eq!(retained["status"], "stopped"); - assert_eq!( - retained["evidenceOrigin"], - format!("http://127.0.0.1:{evidence_port}") + stdout.contains(&format!("Issuer ready at http://127.0.0.1:{issuer_port}")), + "{stdout}" ); + + let state = read_json(&fixture.root.join(".evidence/dev/state.json")); + assert_eq!(state["schema"], "registry.evidencectl.dev-state/v6"); + assert_eq!(state["status"], "ready"); assert_eq!( - retained["mintOrigin"], - format!("http://127.0.0.1:{mint_port}") + state["issuerOrigin"], + format!("http://127.0.0.1:{issuer_port}") ); - let restarted = fixture.dev_restart(&evidence, &mint); - assert_success(&restarted, "canonical restart on retained configured ports"); assert_eq!( - String::from_utf8_lossy(&restarted.stdout), - format!( - "Evidence ready at http://127.0.0.1:{evidence_port}\nMint ready at http://127.0.0.1:{mint_port}\n" - ) - ); - assert!(ready( - &format!("http://127.0.0.1:{evidence_port}/ready"), - json!({"status":"ready"}) - )); - assert!(jwks_ready_at(mint_port)); - assert_success(&fixture.dev_stop(), "stop restarted configured ports"); - - let starting = fixture.dev_start_with_env( - &evidence, - &mint, - "EVIDENCECTL_TEST_PARENT_EXIT_STAGE", - OsStr::new("before-supervisor"), - ); - assert_eq!(starting.status.code(), Some(86)); - assert!( - !fixture.dev_clean().status.success(), - "potentially live Starting replacement remains fail closed" - ); - assert!(dev.is_dir()); - assert!(retained_root.is_dir()); - fs::remove_file(dev.join("state.json")).expect("remove inert injected Starting state"); - assert_success( - &fixture.dev_clean(), - "clean demonstrably inactive interrupted replacement", - ); -} - -#[test] -#[ignore = "exact gate: starts real local Mint and Evidence services"] -fn retained_special_file_refuses_restart_before_any_child_starts() { - const CANARY: &str = "retained-cleanup-canary"; - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let (evidence_port, mint_port) = unused_port_pair(); - - assert_success( - &fixture.dev_start_on_ports(&evidence, &mint, evidence_port, mint_port), - "initial local start", - ); - assert_success(&fixture.dev_stop(), "initial local stop"); - wait_unavailable(&format!("127.0.0.1:{evidence_port}")); - wait_unavailable(&format!("127.0.0.1:{mint_port}")); - - let dev = fixture.root.join(".evidence/dev"); - fs::set_permissions(&dev, fs::Permissions::from_mode(0o700)).expect("writable stopped root"); - let planted = dev.join(CANARY); - assert!( - Command::new("mkfifo") - .arg(&planted) - .status() - .expect("run mkfifo") - .success(), - "mkfifo left no special file" + state["tokenUrl"], + format!("http://127.0.0.1:{issuer_port}/oauth2/token") ); - let pid_directory = fixture.root.join("restart-service-pids"); - fs::create_dir(&pid_directory).expect("PID directory"); - fs::set_permissions(&pid_directory, fs::Permissions::from_mode(0o700)) - .expect("PID directory mode"); + assert!(state.get("mintOrigin").is_none()); - let refused = fixture - .dev_start_command(&evidence, &mint) - .args(["--evidence-port", &evidence_port.to_string()]) - .args(["--mint-port", &mint_port.to_string()]) - .env("EVIDENCECTL_TEST_SERVICE_PID_DIRECTORY", &pid_directory) + let token = evidencectl() + .args(["dev", "token", "local-tutorial-caller"]) + .arg(&fixture.root) .output() - .expect("restart with retained special file"); - - assert_eq!(refused.status.code(), Some(1)); - assert!(refused.stdout.is_empty(), "no ready state may be published"); - let diagnostic = String::from_utf8_lossy(&refused.stderr); - assert!( - diagnostic.contains("error[evidence.dev.failed]"), - "{diagnostic}" - ); - assert!(!diagnostic.contains(CANARY), "{diagnostic}"); - assert!( - !diagnostic.contains(&planted.to_string_lossy().into_owned()), - "{diagnostic}" - ); - assert!(sorted_names(&pid_directory).is_empty()); - assert!(TcpListener::bind(("127.0.0.1", evidence_port)).is_ok()); - assert!(TcpListener::bind(("127.0.0.1", mint_port)).is_ok()); - assert!(!fixture + .expect("dev token"); + assert_success(&token, "dev token"); + let header_path = fixture .root - .join(".evidence/dev-stopped-before-restart") - .exists()); - let state: Value = - serde_json::from_slice(&fs::read(dev.join("state.json")).expect("stopped state")) - .expect("stopped state JSON"); - assert_eq!(state["status"], "stopped"); - - fs::remove_file(&planted).expect("remove planted FIFO"); - let restarted = fixture.dev_start_on_ports(&evidence, &mint, evidence_port, mint_port); - assert_success(&restarted, "ordinary retained-state restart"); - assert_success( - &fixture.dev_stop(), - "ordinary stop after retained-state restart", + .join(".evidence/dev/generated/keys/local-tutorial-caller.header"); + assert_mode(&header_path, 0o600); + let header = fs::read_to_string(&header_path).expect("private authorization header"); + let compact = header + .trim_end() + .strip_prefix("Authorization: Bearer ") + .expect("authorization header prefix"); + let claims = decode_claims(compact); + assert_eq!(claims["registry_actor_kind"], "service"); + assert_eq!(claims["evidence_tags"], serde_json::json!(["local-caller"])); + assert_eq!( + claims["evidence_audience"], + "urn:registrystack:evidence:local:caller" ); + assert_eq!(claims["aud"], "urn:registrystack:evidence:local:gateway"); + assert!(claims.get("registry_grant_id").is_none()); + + assert_success(&fixture.dev_stop(), "dev stop"); + let stopped = read_json(&fixture.root.join(".evidence/dev/state.json")); + assert_eq!(stopped["status"], "stopped"); + assert!(stopped["caller"].is_null()); + assert!(!fixture.root.join(".evidence/dev/control.sock").exists()); + assert_success(&fixture.dev_clean(), "dev clean"); + assert!(!fixture.root.join(".evidence/dev").exists()); } #[test] -#[ignore = "exact gate: starts real Mint and Evidence services"] -fn explicit_access_clients_reload_mint_without_restarting_services() { - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let fixture = Project::new(); - let source_probe = TcpListener::bind("127.0.0.1:0").expect("source call probe"); - source_probe - .set_nonblocking(true) - .expect("nonblocking source call probe"); - fixture.point_source_at( - source_probe - .local_addr() - .expect("source probe address") - .port(), - ); - fixture.add_age_bracket_question(); - fixture.generate_evidence_keys(); - assert_success( - &evidencectl() - .args([ - "access", - "policy", - "add", - "age-checks", - "--question", - "adult-status", - "--project", - ]) - .arg(&fixture.root) - .output() - .expect("add policy"), - "add policy", - ); - assert_success( - &evidencectl() - .args([ - "access", - "policy", - "add", - "service-routing", - "--question", - "age-bracket", - "--project", - ]) - .arg(&fixture.root) - .output() - .expect("add unassigned policy"), - "add policy for the ungranted question", - ); - assert_success( - &add_local_client(&fixture.root, "client-a", "age-checks"), - "add client A", - ); - let client_a_key = fixture.root.join(".evidence/clients/client-a/private.jwk"); - assert_mode(&client_a_key, 0o600); - let external_client_a_key = fixture - ._temporary - .path() - .join("external-client-a-private.jwk"); - fs::copy(&client_a_key, &external_client_a_key).expect("retain external client A key"); - fs::remove_dir_all(client_a_key.parent().unwrap()) - .expect("remove local-only client A key as in a fresh clone"); - assert!( - !client_a_key.exists(), - "the cloned project has only client A's public registration" - ); - - let pid_directory = fixture.root.join("service-pids"); - fs::create_dir(&pid_directory).expect("PID directory"); - fs::set_permissions(&pid_directory, fs::Permissions::from_mode(0o700)) - .expect("PID directory mode"); - let (evidence_port, mint_port) = unused_port_pair(); - let started = fixture - .dev_start_command(&evidence, &mint) - .args(["--evidence-port", &evidence_port.to_string()]) - .args(["--mint-port", &mint_port.to_string()]) - .env("EVIDENCECTL_TEST_SERVICE_PID_DIRECTORY", &pid_directory) - .output() - .expect("start explicit access generation"); - assert_success(&started, "start explicit access generation"); - let evidence_pid = read_pid(&pid_directory.join("evidence.pid")); - let mint_pid = read_pid(&pid_directory.join("mint.pid")); - let generated_clients = fixture.root.join(".evidence/dev/generated/clients"); - assert_mode(&fixture.root.join(".evidence/dev/generated/audit"), 0o700); - assert_mode( - &fixture - .root - .join(".evidence/dev/generated/keys/mint-audit-hmac-key"), - 0o600, - ); - assert_mode( - &fixture - .root - .join(".evidence/dev/generated/audit/mint.jsonl"), - 0o600, - ); - assert_eq!(sorted_names(&generated_clients), ["client-a.yaml"]); - - let added = add_local_client(&fixture.root, "client-b", "age-checks"); - assert_success(&added, "live add client B"); - assert!(String::from_utf8_lossy(&added.stdout).contains("Registry Mint reload requested.")); - assert_eq!( - sorted_names(&generated_clients), - ["client-a.yaml", "client-b.yaml"] - ); - assert_eq!(read_pid(&pid_directory.join("evidence.pid")), evidence_pid); - assert_eq!(read_pid(&pid_directory.join("mint.pid")), mint_pid); - assert!(process_is_alive(evidence_pid)); - assert!(process_is_alive(mint_pid)); - - let prepared = retry_until_success("newly added client B token request", || { - evidencectl() - .args([ - "request", - "prepare", - "adult-status", - "--purpose", - "age-check", - "--subject", - "person_id=person-123", - "--client", - "client-b", - "--name", - "client-b-live", - "--project", - ]) +fn retired_mint_flags_are_refused_before_private_state_is_created() { + for flag in ["--mint-port", "--mint-bin"] { + let fixture = Project::new(); + let value = if flag == "--mint-port" { + "18081" + } else { + "/missing/mint" + }; + let output = evidencectl() + .args(["dev", "--detach", flag, value, "--project"]) .arg(&fixture.root) .output() - .expect("prepare as client B") - }); - assert_success(&prepared, "newly added client B token request"); - - let client_b_key = fixture.root.join(".evidence/clients/client-b/private.jwk"); - let token = direct_mint_token(&mint, mint_port, "client-b", &client_b_key); - assert_success(&token, "direct token for client B"); - let token = String::from_utf8(token.stdout) - .expect("Mint token is UTF-8") - .trim() - .to_owned(); - let status = post_evidence( - evidence_port, - &token, - "urn:registrystack:evidence:local:requirement:age-bracket", - "service-path-selection", - "local-subject-age-bracket-v1", - ); - assert_eq!(status, 403, "an ungranted authored question is forbidden"); - assert_source_not_called(&source_probe); - - let revoked = evidencectl() - .args(["access", "client", "revoke", "client-a", "--project"]) - .arg(&fixture.root) - .output() - .expect("revoke client A"); - assert_success(&revoked, "live revoke client A"); - assert!(String::from_utf8_lossy(&revoked.stdout).contains("Registry Mint reload requested.")); - assert_eq!(sorted_names(&generated_clients), ["client-b.yaml"]); - assert_eq!(read_pid(&pid_directory.join("evidence.pid")), evidence_pid); - assert_eq!(read_pid(&pid_directory.join("mint.pid")), mint_pid); - assert!(process_is_alive(evidence_pid)); - assert!(process_is_alive(mint_pid)); - assert!( - !client_a_key.exists(), - "revocation does not require or recreate client A's local key" - ); - - let direct_refusal = retry_until_mint_refuses(|| { - direct_mint_token(&mint, mint_port, "client-a", &external_client_a_key) - }); - assert!(direct_refusal.stdout.is_empty()); + .expect("retired Mint flag"); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!( + error.contains("Registry Mint development flags were removed"), + "{error}" + ); + assert!(error.contains("--issuer-port"), "{error}"); + assert!(!fixture.root.join(".evidence").exists()); + } +} - let refused = evidencectl() - .args([ - "request", - "prepare", - "adult-status", - "--purpose", - "age-check", - "--subject", - "person_id=person-123", - "--client", - "client-a", - "--name", - "client-a-revoked", - "--project", - ]) - .arg(&fixture.root) +#[test] +fn retired_grant_command_points_to_the_casework_authority() { + let output = evidencectl() + .args(["dev", "grant", "--requirement", "adult-status"]) .output() - .expect("prepare as revoked client A"); + .expect("retired grant command"); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); assert!( - !refused.status.success(), - "revoked client A must be refused" + error.contains("evidence.dev.grant-authority-required"), + "{error}" ); - assert!(String::from_utf8_lossy(&refused.stderr) - .contains("unknown or revoked active client client-a")); - assert!(!fixture - .root - .join(".evidence/requests/client-a-revoked") - .exists()); + assert!(error.contains("configured Casework authority"), "{error}"); + assert!(error.contains("signed assertion"), "{error}"); - let last_revoked = evidencectl() - .args(["access", "client", "revoke", "client-b", "--project"]) - .arg(&fixture.root) + let help = evidencectl() + .args(["dev", "--help"]) .output() - .expect("revoke last client B"); - assert_success(&last_revoked, "live revoke last client B"); + .expect("dev help"); + assert_success(&help, "dev help"); assert!( - String::from_utf8_lossy(&last_revoked.stdout).contains("Registry Mint reload requested.") + !String::from_utf8_lossy(&help.stdout).contains("grant"), + "retired grant command must stay hidden" ); - assert!(sorted_names(&generated_clients).is_empty()); - wait_mint_without_clients(mint_port); - assert!(process_is_alive(evidence_pid)); - assert!(process_is_alive(mint_pid)); - - assert_success(&fixture.dev_stop(), "stop explicit access generation"); - wait_unavailable(&format!("127.0.0.1:{evidence_port}")); - wait_unavailable(&format!("127.0.0.1:{mint_port}")); - assert_success(&fixture.dev_clean(), "clean explicit access generation"); } #[test] @@ -748,7 +196,7 @@ fn equal_local_ports_fail_before_creating_private_state() { "--detach", "--evidence-port", "18080", - "--mint-port", + "--issuer-port", "18080", "--project", ]) @@ -760,258 +208,31 @@ fn equal_local_ports_fail_before_creating_private_state() { } #[test] -#[ignore = "exact gate: owns fixed 127.0.0.1:8081 tutorial port"] -fn mint_port_conflict_fails_without_starting_evidence_or_disturbing_the_listener() { - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let conflict = TcpListener::bind("127.0.0.1:8081").expect("reserve Mint port"); - - let output = fixture.dev_start(&evidence, &mint); - assert!(!output.status.success(), "port conflict must fail"); - assert!(conflict.local_addr().is_ok(), "unrelated listener survives"); - assert!( - TcpListener::bind("127.0.0.1:8080").is_ok(), - "Evidence was not orphaned" - ); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "failed fresh state cleaned" - ); -} - -#[test] -#[ignore = "exact gate: starts real Mint on fixed 127.0.0.1:8081"] -fn evidence_child_failure_stops_mint_and_cleans_the_fresh_session() { - let mint = required_binary("MINT_BIN"); - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let evidence = fixture.root.join("evidence-fails-on-serve"); - fs::write( - &evidence, - "#!/bin/sh\nif [ \"$3\" = check ]; then exit 0; fi\nexit 1\n", - ) - .expect("write Evidence test binary"); - fs::set_permissions(&evidence, fs::Permissions::from_mode(0o700)).expect("test binary mode"); - - let output = fixture.dev_start(&evidence, &mint); - assert!( - !output.status.success(), - "Evidence child failure must fail start" - ); - wait_unavailable("127.0.0.1:8080"); - wait_unavailable("127.0.0.1:8081"); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "failed state cleaned" - ); -} - -#[test] -#[ignore = "exact gate: starts real services on fixed tutorial ports"] -fn ready_state_publication_failure_stops_children_and_allows_a_fresh_start() { - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let fixture = Project::new(); - fixture.generate_evidence_keys(); - - let failed = fixture.dev_start_with_env( - &evidence, - &mint, - "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", - OsStr::new("before-ready-state"), - ); - assert!( - !failed.status.success(), - "state publication fault must fail" - ); - wait_unavailable("127.0.0.1:8080"); - wait_unavailable("127.0.0.1:8081"); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "failed fresh state cleaned" - ); - - let restarted = fixture.dev_start(&evidence, &mint); - assert_success(&restarted, "fresh start after rollback"); - assert_success(&fixture.dev_stop(), "stop fresh start"); -} - -#[test] -#[ignore = "exact gate: starts real services on fixed tutorial ports"] -fn catchable_supervisor_signals_stop_owned_children_and_publish_terminal_state() { - let evidence = required_binary("EVIDENCE_BIN"); - let mint = required_binary("MINT_BIN"); - let mut unrelated = Command::new("/bin/sleep") - .arg("60") - .spawn() - .expect("start unrelated process"); - - for signal in [ - rustix::process::Signal::TERM, - rustix::process::Signal::HUP, - rustix::process::Signal::INT, - ] { - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let pid_file = fixture.root.join("supervisor.pid"); - let started = fixture.dev_start_with_env( - &evidence, - &mint, - "EVIDENCECTL_TEST_SUPERVISOR_PID_FILE", - pid_file.as_os_str(), - ); - assert_success(&started, "dev --detach before supervisor signal"); - - let pid: i32 = fs::read_to_string(&pid_file) - .expect("supervisor pid file") - .trim() - .parse() - .expect("supervisor pid"); - let pid = rustix::process::Pid::from_raw(pid).expect("positive supervisor pid"); - rustix::process::kill_process(pid, signal).expect("signal supervisor"); - - let dev = fixture.root.join(".evidence/dev"); - wait_for_failed_state(&dev); - wait_unavailable("127.0.0.1:8080"); - wait_unavailable("127.0.0.1:8081"); - assert!(!dev.join("control.sock").exists()); - assert!(unrelated.try_wait().expect("unrelated status").is_none()); - } - stop_child(&mut unrelated); -} - -#[test] -fn every_pre_socket_supervisor_failure_rolls_back_without_wedging_the_project() { - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let check_only = fixture.tool_that_never_serves(); - - let missing_supervisor = fixture.root.join("missing-supervisor"); - let failed = fixture.dev_start_on_free_ports_with_env( - &check_only, - &check_only, - "EVIDENCECTL_TEST_SUPERVISOR_BIN", - missing_supervisor.as_os_str(), - ); - assert!(!failed.status.success(), "supervisor spawn fault must fail"); - assert!(!fixture.root.join(".evidence/dev").exists()); - - for stage in ["before-setsid", "before-socket", "after-socket"] { - let failed = fixture.dev_start_on_free_ports_with_env( - &check_only, - &check_only, - "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", - OsStr::new(stage), - ); - assert!(!failed.status.success(), "{stage} fault must fail"); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "{stage} rollback must permit the next fresh start" - ); - } -} - -/// A port another process already holds is the first thing a newcomer hits, -/// and it surfaces as a readiness timeout several seconds later that names -/// neither the port nor a way out. The refusal has to name the port, say what -/// is wrong with it, and give the flag that moves the session elsewhere. -#[test] -fn a_busy_local_port_is_refused_by_name_with_the_flag_that_moves_it() { +fn each_busy_local_port_is_refused_by_name_with_its_recovery_flag() { let fixture = Project::new(); fixture.generate_evidence_keys(); let tool = fixture.tool_that_never_serves(); - let (free_evidence, free_mint) = unused_port_pair(); let busy = TcpListener::bind("127.0.0.1:0").expect("hold a local port"); let busy_port = busy.local_addr().expect("busy address").port(); + let (free_evidence, free_issuer) = unused_port_pair(); - let output = fixture.dev_start_on_ports(&tool, &tool, busy_port, free_mint); - assert!(!output.status.success(), "a busy Evidence port must fail"); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - assert!(stderr.contains(&busy_port.to_string()), "{stderr}"); - assert!(stderr.contains("already in use"), "{stderr}"); - assert!(stderr.contains("--evidence-port"), "{stderr}"); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "a refused port must leave no session behind" - ); - - let output = fixture.dev_start_on_ports(&tool, &tool, free_evidence, busy_port); - assert!(!output.status.success(), "a busy Mint port must fail"); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - assert!(stderr.contains(&busy_port.to_string()), "{stderr}"); - assert!(stderr.contains("already in use"), "{stderr}"); - assert!( - stderr.contains("--mint-port"), - "each port names its own flag: {stderr}" - ); - assert!( - busy.local_addr().is_ok(), - "the unrelated listener must survive" - ); -} - -/// The rollback that keeps the next start fresh also removed the only record -/// of why this one failed. The logs move up beside the session instead, and -/// the failure says where they are. -#[test] -fn a_failed_start_keeps_its_startup_logs_and_names_where_they_are() { - let fixture = Project::new(); - fixture.generate_evidence_keys(); - let tool = fixture.tool_that_never_serves(); - - let failed = fixture.dev_start_on_free_ports_with_env( - &tool, - &tool, - "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", - OsStr::new("before-socket"), - ); - assert!(!failed.status.success(), "the injected fault must fail"); - let stderr = String::from_utf8_lossy(&failed.stderr).into_owned(); - let kept = fixture.root.join(".evidence/failed-start"); - assert!( - stderr.contains(&kept.to_string_lossy().into_owned()), - "the failure must name where its logs are: {stderr}" - ); - assert!( - !stderr.contains("Some("), - "a recorded program value is not a diagnostic: {stderr}" - ); - assert!( - !fixture.root.join(".evidence/dev").exists(), - "the incomplete session is still rolled back" - ); - - assert_mode(&kept, 0o700); - let supervisor_log = kept.join("supervisor.log"); - assert_mode(&supervisor_log, 0o600); - assert!( - !fs::read_to_string(&supervisor_log) - .expect("kept supervisor log") - .trim() - .is_empty(), - "the kept log must carry the supervisor's own diagnostic" - ); - - // One record, of the last failed start: an attempt per directory would - // grow without bound and leave the reader choosing between them. - let failed = fixture.dev_start_on_free_ports_with_env( - &tool, - &tool, - "EVIDENCECTL_TEST_SUPERVISOR_FAIL_STAGE", - OsStr::new("before-setsid"), - ); - assert!(!failed.status.success(), "the second fault must fail"); - assert_eq!(sorted_names(&kept), vec!["supervisor.log".to_owned()]); - - // Keeping the logs must not wedge the project the way an incomplete - // session would. - assert!(!fixture.root.join(".evidence/dev").exists()); + for (evidence_port, issuer_port, flag) in [ + (busy_port, free_issuer, "--evidence-port"), + (free_evidence, busy_port, "--issuer-port"), + ] { + let output = fixture.dev_start(&tool, &tool, evidence_port, issuer_port); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains(&busy_port.to_string()), "{error}"); + assert!(error.contains("already in use"), "{error}"); + assert!(error.contains(flag), "{error}"); + assert!(!fixture.root.join(".evidence/dev").exists()); + } + assert!(busy.local_addr().is_ok()); } #[test] -fn public_symlink_and_stale_state_fail_before_binary_or_process_access() { +fn public_symlink_and_stale_state_are_never_replaced() { let public = Project::new(); fs::create_dir(public.root.join(".evidence")).expect("generated root"); fs::set_permissions( @@ -1019,12 +240,7 @@ fn public_symlink_and_stale_state_fail_before_binary_or_process_access() { fs::Permissions::from_mode(0o755), ) .expect("public mode"); - let output = evidencectl() - .args(["dev", "--detach", "--project"]) - .arg(&public.root) - .output() - .expect("public-state start"); - assert!(!output.status.success()); + assert!(!public.start_without_binaries().status.success()); assert!(!public.dev_clean().status.success()); let linked = Project::new(); @@ -1032,13 +248,7 @@ fn public_symlink_and_stale_state_fail_before_binary_or_process_access() { fs::create_dir(&target).expect("target"); fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).expect("target mode"); symlink(&target, linked.root.join(".evidence")).expect("generated symlink"); - let output = evidencectl() - .args(["dev", "--detach", "--project"]) - .arg(&linked.root) - .output() - .expect("symlink-state start"); - assert!(!output.status.success()); - assert!(!linked.dev_clean().status.success()); + assert!(!linked.start_without_binaries().status.success()); assert!(linked.root.join(".evidence").is_symlink()); let stale = Project::new(); @@ -1047,28 +257,20 @@ fn public_symlink_and_stale_state_fail_before_binary_or_process_access() { stale.root.join(".evidence"), fs::Permissions::from_mode(0o700), ) - .expect("generated mode"); + .expect("private mode"); fs::create_dir(stale.root.join(".evidence/dev")).expect("stale dev"); fs::set_permissions( stale.root.join(".evidence/dev"), fs::Permissions::from_mode(0o700), ) .expect("stale mode"); - fs::write(stale.root.join(".evidence/dev/unknown"), b"do not remove").expect("stale entry"); - let mut unrelated = Command::new("/bin/sleep") - .arg("60") - .spawn() - .expect("unrelated process"); - let output = evidencectl() - .args(["dev", "--detach", "--project"]) - .arg(&stale.root) - .output() - .expect("stale-state start"); - assert!(!output.status.success()); + fs::write(stale.root.join(".evidence/dev/unknown"), b"keep").expect("stale entry"); + assert!(!stale.start_without_binaries().status.success()); assert!(!stale.dev_clean().status.success()); - assert!(stale.root.join(".evidence/dev/unknown").is_file()); - assert!(unrelated.try_wait().expect("unrelated status").is_none()); - stop_child(&mut unrelated); + assert_eq!( + fs::read(stale.root.join(".evidence/dev/unknown")).expect("preserved stale entry"), + b"keep" + ); } struct Project { @@ -1078,20 +280,17 @@ struct Project { impl Project { fn new() -> Self { - Self::at_relative_path(Path::new("tutorial")) - } - - fn new_long_path() -> Self { - Self::at_relative_path(Path::new( - "first-evidence-assertion-with-a-deliberately-long-project-directory/adult-status-with-a-long-adopter-project-name", - )) - } - - fn at_relative_path(relative: &Path) -> Self { - let temporary = tempfile::tempdir().expect("tempdir"); - let root = temporary.path().join(relative); - fs::create_dir_all(&root).expect("project"); - fs::create_dir(root.join("questions")).expect("questions"); + // The issuer bind-mounts this state into Docker. Keep it beside the + // built binary rather than under a host TMPDIR Docker may not share. + let binary = Path::new(env!("CARGO_BIN_EXE_evidencectl")); + let temporary = tempfile::Builder::new() + .prefix("evidence-native-lifecycle-") + .tempdir_in(binary.parent().expect("binary parent")) + .expect("tempdir"); + fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700)) + .expect("tempdir mode"); + let root = temporary.path().join("tutorial"); + fs::create_dir_all(root.join("questions")).expect("questions"); fs::create_dir(root.join("derivations")).expect("derivations"); fs::write(root.join("source.openapi.yaml"), OPENAPI).expect("OpenAPI"); fs::write(root.join("questions/adult-status.yaml"), QUESTION).expect("question"); @@ -1102,20 +301,6 @@ impl Project { } } - /// A stand-in for both service binaries that answers every compile and - /// check step and refuses only to serve, so a start reaches the - /// supervisor without ever binding a port. - fn tool_that_never_serves(&self) -> PathBuf { - let path = self.root.join("never-serves-tool"); - fs::write( - &path, - "#!/bin/sh\ncase \"$*\" in\n render-discovery-description*) printf '{}\\n';;\n *serve*) exit 1;;\nesac\n", - ) - .expect("write the never-serving tool"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).expect("tool mode"); - path - } - fn generate_evidence_keys(&self) { let secrets = self.root.join("secrets"); assert_success( @@ -1138,112 +323,49 @@ impl Project { } } - fn point_source_at(&self, port: u16) { - fs::write( - self.root.join("source.openapi.yaml"), - OPENAPI.replace("http://127.0.0.1:8000", &format!("http://127.0.0.1:{port}")), - ) - .expect("update source origin"); - } - - fn add_age_bracket_question(&self) { - fs::write( - self.root.join("questions/age-bracket.yaml"), - AGE_BRACKET_QUESTION, - ) - .expect("age-bracket question"); - fs::write( - self.root.join("derivations/age-bracket.rhai"), - AGE_BRACKET_DERIVATION, - ) - .expect("age-bracket derivation"); - } - - fn dev_start(&self, evidence: &Path, mint: &Path) -> Output { - self.dev_start_command(evidence, mint) - .output() - .expect("dev --detach") + fn tool_that_never_serves(&self) -> PathBuf { + let path = self.root.join("never-serves-tool"); + fs::write(&path, "#!/bin/sh\nexit 1\n").expect("test tool"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).expect("test tool mode"); + path } - fn dev_start_on_ports( + fn dev_start( &self, evidence: &Path, - mint: &Path, + docker: &Path, evidence_port: u16, - mint_port: u16, - ) -> Output { - self.dev_start_command(evidence, mint) - .args(["--evidence-port", &evidence_port.to_string()]) - .args(["--mint-port", &mint_port.to_string()]) - .output() - .expect("dev --detach on configured ports") - } - - fn dev_start_with_env( - &self, - evidence: &Path, - mint: &Path, - name: &str, - value: &OsStr, + issuer_port: u16, ) -> Output { - self.dev_start_command(evidence, mint) - .env(name, value) - .output() - .expect("dev --detach with test fault") - } - - /// The fixed tutorial ports are an exact gate of their own, so a fault - /// that never reaches a listener asks for ports nobody else owns. - fn dev_start_on_free_ports_with_env( - &self, - evidence: &Path, - mint: &Path, - name: &str, - value: &OsStr, - ) -> Output { - let (evidence_port, mint_port) = unused_port_pair(); - self.dev_start_command(evidence, mint) - .args(["--evidence-port", &evidence_port.to_string()]) - .args(["--mint-port", &mint_port.to_string()]) - .env(name, value) - .output() - .expect("dev --detach with test fault") - } - - fn dev_start_command(&self, evidence: &Path, mint: &Path) -> Command { - let mut command = evidencectl(); - command + evidencectl() .args(["dev", "--detach", "--project"]) .arg(&self.root) .arg("--evidence-bin") .arg(evidence) - .arg("--mint-bin") - .arg(mint) + .arg("--docker-bin") + .arg(docker) + .args(["--evidence-port", &evidence_port.to_string()]) + .args(["--issuer-port", &issuer_port.to_string()]) .arg("--ready-timeout-seconds") - .arg("20"); - command + .arg("120") + .output() + .expect("dev start") } - fn dev_stop(&self) -> Output { + fn start_without_binaries(&self) -> Output { evidencectl() - .args(["dev", "stop", "--project"]) + .args(["dev", "--detach", "--project"]) .arg(&self.root) .output() - .expect("dev stop") + .expect("dev start") } - fn dev_restart(&self, evidence: &Path, mint: &Path) -> Output { + fn dev_stop(&self) -> Output { evidencectl() - .args(["dev", "start"]) + .args(["dev", "stop", "--project"]) .arg(&self.root) - .arg("--evidence-bin") - .arg(evidence) - .arg("--mint-bin") - .arg(mint) - .arg("--ready-timeout-seconds") - .arg("20") .output() - .expect("canonical dev restart") + .expect("dev stop") } fn dev_clean(&self) -> Output { @@ -1259,137 +381,48 @@ fn evidencectl() -> Command { Command::new(env!("CARGO_BIN_EXE_evidencectl")) } -fn add_local_client(project: &Path, client: &str, policy: &str) -> Output { - evidencectl() - .args([ - "access", - "client", - "add", - client, - "--policy", - policy, - "--generate-local-key", - "--project", - ]) - .arg(project) - .output() - .expect("add local client") -} - -fn direct_mint_token(mint: &Path, port: u16, client: &str, key: &Path) -> Output { - let token_url = format!("http://127.0.0.1:{port}/token"); - Command::new(mint) - .arg("token") - .arg("--url") - .arg(&token_url) - .arg("--audience") - .arg(&token_url) - .arg("--client-id") - .arg(client) - .arg("--key") - .arg(key) - .output() - .expect("invoke Mint token client") -} - -fn post_evidence( - port: u16, - token: &str, - requirement: &str, - purpose: &str, - selector_profile: &str, -) -> u16 { - let body = json!({ - "requestNonce": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "requirement": requirement, - "purpose": purpose, - "subjects": [{ - "role": "person", - "selector": { - "profile": selector_profile, - "values": {"person_id": "person-123"}, - }, - }], - }); - let body = body.to_string(); - match ureq::post(&format!("http://127.0.0.1:{port}/v1/evidence")) - .set("Authorization", &format!("Bearer {token}")) - .set("Accept", "application/jose+json") - .set("Content-Type", "application/json") - .send_string(&body) - { - Ok(response) => response.status(), - Err(ureq::Error::Status(status, _)) => status, - Err(error) => panic!("Evidence request failed before an HTTP response: {error}"), - } -} - -fn assert_source_not_called(source_probe: &TcpListener) { - match source_probe.accept() { - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} - Ok(_) => panic!("Evidence contacted the source for an ungranted question"), - Err(error) => panic!("source call probe failed: {error}"), - } -} - -fn retry_until_success(label: &str, mut operation: impl FnMut() -> Output) -> Output { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let output = operation(); - if output.status.success() { - return output; - } - if Instant::now() >= deadline { - panic!( - "{label} did not succeed after Mint reload\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - thread::sleep(Duration::from_millis(50)); - } +fn required_binary(name: &str) -> PathBuf { + std::env::var_os(name) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("set {name} for the exact lifecycle gate")) } -fn retry_until_mint_refuses(mut operation: impl FnMut() -> Output) -> Output { - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let output = operation(); - let refused = !output.status.success() - && output.stdout.is_empty() - && String::from_utf8_lossy(&output.stderr).contains("invalid_client"); - if refused { - return output; - } - if Instant::now() >= deadline { - panic!( - "Mint did not refuse revoked client A after reload\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - thread::sleep(Duration::from_millis(50)); +fn installed_or_env(variable: &str, binary: &str) -> PathBuf { + if let Some(path) = std::env::var_os(variable) { + return PathBuf::from(path); } + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(binary)) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| panic!("set {variable} for the exact lifecycle gate")) } -fn read_pid(path: &Path) -> u32 { - fs::read_to_string(path) - .unwrap_or_else(|error| panic!("read {}: {error}", path.display())) - .trim() - .parse() - .expect("numeric PID") +fn unused_port_pair() -> (u16, u16) { + let first = TcpListener::bind("127.0.0.1:0").expect("reserve first port"); + let second = TcpListener::bind("127.0.0.1:0").expect("reserve second port"); + let ports = ( + first.local_addr().expect("first address").port(), + second.local_addr().expect("second address").port(), + ); + drop((first, second)); + ports } -fn process_is_alive(pid: u32) -> bool { - Command::new("/bin/kill") - .args(["-0", &pid.to_string()]) - .status() - .is_ok_and(|status| status.success()) +fn decode_claims(compact: &str) -> Value { + let payload = compact.split('.').nth(1).expect("JWT payload"); + serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload) + .expect("JWT payload encoding"), + ) + .expect("JWT claims") } -fn required_binary(name: &str) -> PathBuf { - std::env::var_os(name) - .map(PathBuf::from) - .unwrap_or_else(|| panic!("set {name} for the exact lifecycle gate")) +fn read_json(path: &Path) -> Value { + serde_json::from_slice( + &fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .unwrap_or_else(|error| panic!("parse {}: {error}", path.display())) } fn assert_success(output: &Output, label: &str) { @@ -1401,45 +434,6 @@ fn assert_success(output: &Output, label: &str) { ); } -fn ready(url: &str, expected: Value) -> bool { - ureq::get(url) - .call() - .ok() - .and_then(|response| serde_json::from_reader::<_, Value>(response.into_reader()).ok()) - == Some(expected) -} - -fn jwks_ready() -> bool { - jwks_ready_at(8081) -} - -fn jwks_ready_at(port: u16) -> bool { - ureq::get(&format!("http://127.0.0.1:{port}/.well-known/jwks.json")) - .call() - .ok() - .and_then(|response| serde_json::from_reader::<_, Value>(response.into_reader()).ok()) - .and_then(|value| value["keys"].as_array().cloned()) - .is_some_and(|keys| { - keys.iter().any(|key| { - key["kty"] == "EC" - && key["crv"] == "P-256" - && key["alg"] == "ES256" - && key["kid"].as_str().is_some_and(|kid| kid.len() == 43) - }) - }) -} - -fn unused_port_pair() -> (u16, u16) { - let first = TcpListener::bind("127.0.0.1:0").expect("reserve first port"); - let second = TcpListener::bind("127.0.0.1:0").expect("reserve second port"); - let ports = ( - first.local_addr().expect("first address").port(), - second.local_addr().expect("second address").port(), - ); - drop((first, second)); - ports -} - fn assert_mode(path: &Path, expected: u32) { let metadata = fs::symlink_metadata(path) .unwrap_or_else(|error| panic!("inspect {}: {error}", path.display())); @@ -1451,63 +445,3 @@ fn assert_mode(path: &Path, expected: u32) { ); assert_eq!(metadata.uid(), rustix::process::getuid().as_raw()); } - -fn sorted_names(root: &Path) -> Vec { - let mut names = fs::read_dir(root) - .expect("directory") - .map(|entry| { - entry - .expect("entry") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect::>(); - names.sort(); - names -} - -fn wait_unavailable(address: &str) { - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if TcpListener::bind(address).is_ok() { - return; - } - thread::sleep(Duration::from_millis(50)); - } - panic!("{address} remained occupied"); -} - -fn wait_mint_without_clients(port: u16) { - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - if matches!( - ureq::get(&format!("http://127.0.0.1:{port}/ready")).call(), - Err(ureq::Error::Status(503, _)) - ) { - return; - } - thread::sleep(Duration::from_millis(50)); - } - panic!("Mint did not publish its empty-registry readiness state"); -} - -fn wait_for_failed_state(dev: &Path) { - let deadline = Instant::now() + Duration::from_secs(45); - while Instant::now() < deadline { - if let Ok(bytes) = fs::read(dev.join("state.json")) { - if let Ok(state) = serde_json::from_slice::(&bytes) { - if state["status"] == "failed" && state["failure"] == "supervisor-signal" { - return; - } - } - } - thread::sleep(Duration::from_millis(50)); - } - panic!("supervisor did not publish terminal failed state"); -} - -fn stop_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} diff --git a/crates/registry-evidencectl/tests/output_format.rs b/crates/registry-evidencectl/tests/output_format.rs index c4c47b9fb4..7fc37f8168 100644 --- a/crates/registry-evidencectl/tests/output_format.rs +++ b/crates/registry-evidencectl/tests/output_format.rs @@ -79,8 +79,8 @@ fn global_json_refuses_every_legacy_human_renderer_before_dispatch() { "dev", "--evidence-bin", "evidence", - "--mint-bin", - "mint", + "--docker-bin", + "docker", "--ready-timeout-seconds", "1", ], diff --git a/crates/registry-evidencectl/tests/production_handoff.rs b/crates/registry-evidencectl/tests/production_handoff.rs index d5db65ca97..a5ee53733c 100644 --- a/crates/registry-evidencectl/tests/production_handoff.rs +++ b/crates/registry-evidencectl/tests/production_handoff.rs @@ -691,11 +691,11 @@ fn production_build_checks_and_evaluates_every_neutral_authoring_shape() { } #[test] -#[ignore = "exact gate: starts and stops real local Evidence and Mint before production build"] +#[ignore = "exact gate: starts and stops real local Evidence and the pinned issuer before production build"] fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { let fixture = Fixture::new(); let evidence = evidence_binary(); - let mint = mint_binary(); + let docker = installed_binary("DOCKER_BIN", "docker"); let retained_openapi = fixture.root.join("lifecycle.openapi.yaml"); fs::write( &retained_openapi, @@ -731,10 +731,10 @@ fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { .arg(&fixture.project) .arg("--evidence-bin") .arg(evidence) - .arg("--mint-bin") - .arg(mint) + .arg("--docker-bin") + .arg(docker) .args(["--evidence-port", &fixture.evidence_port.to_string()]) - .args(["--mint-port", &fixture.mint_port.to_string()]) + .args(["--issuer-port", &fixture.mint_port.to_string()]) .args(["--ready-timeout-seconds", "20"]) .output() .expect("public dev starts"), @@ -747,7 +747,7 @@ fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { fixture.evidence_port ))); assert!(started_stdout.contains(&format!( - "Mint ready at http://127.0.0.1:{}", + "Issuer ready at http://127.0.0.1:{}", fixture.mint_port ))); let dev_root = fixture.project.join(".evidence/dev"); @@ -2789,6 +2789,16 @@ fn evidence_binary() -> &'static Path { }) } +fn installed_binary(variable: &str, name: &str) -> PathBuf { + if let Some(path) = std::env::var_os(variable) { + return PathBuf::from(path); + } + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| panic!("set {variable} for this exact lifecycle gate")) +} + fn mint_binary() -> &'static Path { static BINARY: OnceLock = OnceLock::new(); BINARY.get_or_init(|| { diff --git a/crates/registry-evidencectl/tests/request_verify.rs b/crates/registry-evidencectl/tests/request_verify.rs index a6bcfa5845..6de762317d 100644 --- a/crates/registry-evidencectl/tests/request_verify.rs +++ b/crates/registry-evidencectl/tests/request_verify.rs @@ -1,20 +1,27 @@ use std::{ fs, + net::TcpListener, os::unix::{ fs::{symlink, PermissionsExt as _}, net::UnixListener, }, path::{Path, PathBuf}, process::{Command, Output}, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + thread, }; +use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Json, Router}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{Duration, SecondsFormat, Utc}; use p256::ecdsa::{signature::Signer as _, Signature, SigningKey}; use registry_platform_crypto::PublicJwk; use serde_json::{json, Value}; -const TOKEN: &str = "secret.token-canary"; +const TOKEN: &str = "secret.token.canary"; const BINDING: &str = "urn:evidence:subject:v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const AGE_CHECKS_TAG: &str = "policy-v1-bc8c04f766133dc6ffd6e395caa64f9c3b43301c1d308716668c71b8b839c0dc"; @@ -98,8 +105,6 @@ fn owner_only_subjects_file_keeps_selector_values_out_of_command_arguments() { "--evidence-bin", ]) .arg(&fixture.evidence) - .arg("--mint-bin") - .arg(&fixture.mint) .output() .expect("prepare from subjects file"); assert_success(&prepared); @@ -149,8 +154,6 @@ fn owner_only_subjects_file_keeps_selector_values_out_of_command_arguments() { "--evidence-bin", ]) .arg(&fixture.evidence) - .arg("--mint-bin") - .arg(&fixture.mint) .output() .expect("refuse unsafe subjects file"); assert!(!refused.status.success()); @@ -226,25 +229,7 @@ fn prepare_and_verify_delegate_exactly_and_publish_only_safe_artifacts() { format!("header = \"Authorization: Bearer {TOKEN}\"\n") ); - let mint_args = fs::read_to_string(fixture.mint.with_extension("args")).unwrap(); - assert_eq!( - mint_args.lines().collect::>(), - [ - "token", - "--url", - "http://127.0.0.1:8081/token", - "--client-id", - "local-tutorial-caller", - "--key", - fs::canonicalize(&fixture.root) - .unwrap() - .join(".evidence/dev/generated/keys/caller-private.jwk") - .to_str() - .unwrap(), - "--audience", - "http://127.0.0.1:8081/token", - ] - ); + assert!(fixture.token_server.request_count() >= 1); let evidence_args = fs::read_to_string(fixture.evidence.with_extension("prepare.args")) .expect("Evidence prepare argv"); let evidence_args = evidence_args.lines().collect::>(); @@ -376,25 +361,7 @@ fn named_client_prepare_uses_the_registered_identity() { let prepared = fixture.prepare_as("age-checker", "named-client"); assert_success(&prepared); - let mint_args = fs::read_to_string(fixture.mint.with_extension("args")).unwrap(); - assert_eq!( - mint_args.lines().collect::>(), - [ - "token", - "--url", - "http://127.0.0.1:8081/token", - "--client-id", - "age-checker", - "--key", - fs::canonicalize(&fixture.root) - .unwrap() - .join(".evidence/clients/age-checker/private.jwk") - .to_str() - .unwrap(), - "--audience", - "http://127.0.0.1:8081/token", - ] - ); + assert!(fixture.token_server.request_count() >= 1); assert!(fixture .root .join(".evidence/requests/named-client/request.json") @@ -427,7 +394,7 @@ fn named_client_prepare_uses_the_registered_identity() { } #[test] -fn unusable_named_clients_and_mint_refusal_publish_no_request_artifacts() { +fn unusable_named_clients_and_issuer_refusal_publish_no_request_artifacts() { let unknown = Fixture::new(); unknown.add_named_client("other-client", "active", 0o600); unknown.use_explicit_access(); @@ -489,14 +456,11 @@ fn unusable_named_clients_and_mint_refusal_publish_no_request_artifacts() { let refused = Fixture::new(); refused.add_named_client("refused-client", "active", 0o600); refused.use_explicit_access(); - fs::write(refused.mint.with_extension("fail"), b"").unwrap(); + refused.token_server.refuse(); let output = refused.prepare_as("refused-client", "refused-client"); assert!(!output.status.success()); assert!(!String::from_utf8_lossy(&output.stderr).contains(TOKEN)); - assert_eq!( - String::from_utf8_lossy(&output.stderr), - "evidencectl: Registry Mint refused a token for client refused-client\n" - ); + assert!(String::from_utf8_lossy(&output.stderr).contains("local issuer declined")); assert_no_request_artifacts(&refused.root, "refused-client"); } @@ -731,12 +695,109 @@ fn failed_client_verification_removes_the_unpublished_output() { .all(|name| !name.starts_with(".verify-"))); } +struct TokenServer { + address: std::net::SocketAddr, + refuse: Arc, + requests: Arc, + shutdown: Option>, + thread: Option>, +} + +#[derive(Clone)] +struct TokenServerState { + refuse: Arc, + requests: Arc, +} + +impl TokenServer { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("token endpoint"); + listener + .set_nonblocking(true) + .expect("nonblocking token endpoint"); + let address = listener.local_addr().expect("token endpoint address"); + let refuse = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(AtomicUsize::new(0)); + let state = TokenServerState { + refuse: Arc::clone(&refuse), + requests: Arc::clone(&requests), + }; + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let thread = thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("token runtime") + .block_on(async move { + let listener = + tokio::net::TcpListener::from_std(listener).expect("async token listener"); + let app = Router::new() + .route("/oauth2/token", post(test_token_endpoint)) + .with_state(state); + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + .expect("test token endpoint"); + }); + }); + Self { + address, + refuse, + requests, + shutdown: Some(shutdown), + thread: Some(thread), + } + } + + fn address(&self) -> std::net::SocketAddr { + self.address + } + + fn refuse(&self) { + self.refuse.store(true, Ordering::Relaxed); + } + + fn request_count(&self) -> usize { + self.requests.load(Ordering::Relaxed) + } +} + +async fn test_token_endpoint(State(state): State) -> impl IntoResponse { + state.requests.fetch_add(1, Ordering::Relaxed); + if state.refuse.load(Ordering::Relaxed) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error":"invalid_client"})), + ) + .into_response(); + } + Json(json!({ + "access_token": TOKEN, + "token_type": "Bearer", + "expires_in": 300 + })) + .into_response() +} + +impl Drop for TokenServer { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().expect("token endpoint thread"); + } + } +} + struct Fixture { _temporary: tempfile::TempDir, _listener: UnixListener, + token_server: TokenServer, root: PathBuf, evidence: PathBuf, - mint: PathBuf, signing_key: SigningKey, signing_jwk: Value, } @@ -752,24 +813,31 @@ impl Fixture { private_directory(&root.join(".evidence/dev/generated/keys")); private_file(&root.join(".evidence/dev/runtime.yaml"), b"runtime", 0o400); let caller_key = root.join(".evidence/dev/generated/keys/caller-private.jwk"); - private_file(&caller_key, b"{}", 0o600); + private_file( + &caller_key, + br#"{"kty":"OKP","crv":"Ed25519","kid":"local-tutorial-caller-key-1","alg":"EdDSA","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo","d":"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"}"#, + 0o600, + ); let socket = root.join(".evidence/dev/control.sock"); let listener = UnixListener::bind(&socket).expect("control socket"); fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); let canonical = fs::canonicalize(&root).unwrap(); + let token_server = TokenServer::start(); + let issuer_origin = format!("http://{}", token_server.address()); let state = json!({ - "schema": "registry.evidencectl.dev-state/v5", + "schema": "registry.evidencectl.dev-state/v6", "status": "ready", "project": canonical, "runtimePath": canonical.join(".evidence/dev/runtime.yaml"), "evidenceOrigin": "http://127.0.0.1:8080", - "mintOrigin": "http://127.0.0.1:8081", - "tokenUrl": "http://127.0.0.1:8081/token", - "accessTokenAudience": "registry-evidence-local", + "issuerOrigin": issuer_origin, + "issuerSessionId": "0123456789abcdef0123456789abcdef0123456789abcdef", + "tokenUrl": format!("{issuer_origin}/oauth2/token"), + "accessTokenAudience": "urn:registrystack:evidence:local:gateway", "caller": { "clientId": "local-tutorial-caller", "privateKeyPath": canonical.join(".evidence/dev/generated/keys/caller-private.jwk"), - "assertionAudience": "http://127.0.0.1:8081/token", + "assertionAudience": issuer_origin, "evidenceAudience": "urn:registrystack:evidence:local:caller", "requesterTag": "local-caller" }, @@ -837,14 +905,6 @@ impl Fixture { ); write_sealed_bundle(&root, &state); - let mint = temporary.path().join("mint-stub"); - executable( - &mint, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\n[ ! -f \"$0.fail\" ] || exit 31\nprintf '%s\\n' '{TOKEN}'\n" - ) - .as_bytes(), - ); let signing_key = SigningKey::from_slice(&[7_u8; 32]).expect("test signing key"); let point = signing_key.verifying_key().to_encoded_point(false); let mut signing_jwk = json!({ @@ -865,9 +925,9 @@ impl Fixture { Self { _temporary: temporary, _listener: listener, + token_server, root, evidence, - mint, signing_key, signing_jwk, } @@ -982,8 +1042,6 @@ impl Fixture { .args(inputs) .args(["--name", name, "--project", ".", "--evidence-bin"]) .arg(&self.evidence) - .arg("--mint-bin") - .arg(&self.mint) .output() .expect("prepare command") } diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 1b0f81930d..f9c1d1c9ce 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -722,9 +722,9 @@ tracing headers. For OAuth, token acquisition is credential bootstrap rather than an evidence-data source call, and a source authenticates either with a client secret or with a private-key JWT assertion, the asymmetric form health profiles require. Rust owns the fixed token endpoint, grant, client -authentication form, credential placement, audience, token lifetime handling, -bounds, and redaction. Rhai sees neither the credential flow, the signing key, -nor the resulting token. +authentication form, credential placement, audience, optional RFC 8707 resource +indicator, token lifetime handling, bounds, and redaction. Rhai sees neither +the credential flow, the signing key, nor the resulting token. The explicit local assurance profile may additionally use `kind: none` for a source at one canonical numeric-loopback HTTP origin with an explicit non-zero diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 47eb9b3688..58ef9a4820 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -343,9 +343,10 @@ request and selects one generic authentication profile: - static API-key header with a fixed allowlisted header name and secret reference; - OAuth 2.0 client credentials with a client identifier reference, a fixed - HTTPS token endpoint, a fixed grant, an optional fixed scope and audience, - and exactly one client authentication form: a client secret reference with a - fixed placement, or a private-key reference the runtime signs a JWT client + HTTPS token endpoint, a fixed grant, optional fixed scope and audience, an + optional RFC 8707 resource indicator, and exactly one client authentication + form: a client secret reference with a fixed placement, or a private-key + reference the runtime signs a JWT client assertion with. Credential acquisition is not available to Rhai. OAuth token acquisition may diff --git a/products/evidence/README.md b/products/evidence/README.md index a4ba32e9de..cca0154600 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -99,9 +99,9 @@ OpenAPI document exactly as `source.openapi.yaml` and creates empty `questions/`, `derivations/`, and `fixtures/` directories. It always creates owner-only disposable local P-256 Evidence signing material plus distinct audit and subject-binding masters. The command does not select an API operation, -invent a question, fixture, policy, production target, Mint configuration, or +invent a question, fixture, policy, production target, issuer configuration, or deployable bundle. `evidencectl dev start` additionally creates session-scoped P-256 -Mint, caller, and holder keys so the local happy path needs no key ceremony. +caller and holder keys and starts the pinned local issuer so the local happy path needs no key ceremony. `evidencectl init --transport sqlite-extract --profile local` needs no OpenAPI document. It creates a source-neutral synthetic statement source, diff --git a/products/evidence/contracts/audit-event.schema.yaml b/products/evidence/contracts/audit-event.schema.yaml index 71c9a3a067..976019f6ae 100644 --- a/products/evidence/contracts/audit-event.schema.yaml +++ b/products/evidence/contracts/audit-event.schema.yaml @@ -12,7 +12,9 @@ required: - phase - bundleRevision - requesterPseudonym + - actorKind - decision + - reason - durationMilliseconds properties: schema: @@ -28,6 +30,9 @@ properties: bundleRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} requesterPseudonym: {$ref: '#/$defs/pseudonym'} + actorKind: {enum: [human, agent, service]} + clientPseudonym: {$ref: '#/$defs/pseudonym'} + grantPseudonym: {$ref: '#/$defs/pseudonym'} actorPseudonym: {$ref: '#/$defs/pseudonym'} authority: type: object @@ -64,6 +69,7 @@ properties: items: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} decision: enum: [authorized, released, not-authorized, no-match, ambiguous, unresolved, fact-missing, dependency-failure, evaluation-failure, signing-failure] + reason: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} disclosedConcepts: type: array maxItems: 16 @@ -157,7 +163,7 @@ allOf: then: {properties: {adapterIds: {minItems: 5, maxItems: 5}}} audit_rules: authorization_refusal_gate: After successful authentication, an authorization refusal is durably accepted before the generic HTTP 403 is returned. Audit failure changes the outward result to service.unavailable with HTTP 503. - authorization_refusal_minimization: The refusal event contains only its chain and event identifiers, server-minted operation, timestamp and duration, assurance profile, bundle revision, scoped requester pseudonym, optional actor pseudonym, and the closed denial category and decision. The public trace identifier never becomes this operation identity. Requester and actor pseudonym scope binds the operator trust domain, requested purpose, and authenticated audience, while those scope inputs remain omitted from the event. It omits the untrusted requested requirement, purpose, subjects, unmatched authority, selector information, response protection, and source or evaluation material. + authorization_refusal_minimization: The refusal event contains only its chain and event identifiers, server-minted operation, timestamp and duration, assurance profile, bundle revision, actor kind, scoped requester and optional client, grant, or actor pseudonyms, and closed denial reason, category, and decision. The public trace identifier never becomes this operation identity. Pseudonym scope binds the operator trust domain, requested purpose, and authenticated audience, while those scope inputs remain omitted from the event. It omits the untrusted requested requirement, purpose, subjects, unmatched authority, selector information, response protection, and source or evaluation material. access_gate: One access-attempt is durably accepted after authorization and before credential acquisition or source access for every actual source stage; search-then-fetch therefore records search access and, only after a unique validated match, fetch access, and search-then-fetch-set records search access followed by one access per declared fetch member in declared order, each reached only after every earlier stage resolved. A stage that is never reached records nothing. unresolved_decision: A configured exact source-level unresolved Problem Details outcome records the neutral denial decision unresolved only when it terminates a singular acquisition or search stage. It does not assert no-match or ambiguity, and the event records none of the upstream problem body, type, code, detail, or trace. The same outcome after a unique search is a dependency failure because the acquisition has already committed to a fetch or member reference. release_gate: disclosure-release is durably accepted after the final immutable response bytes are serialized and before those exact bytes are released. One operation has exactly one terminal release event, whatever it released; a release carrying more than one assertion names the complete released set in evidenceIds, in release order, instead of naming a single assertion in evidenceId. Splitting a batch into one event per member is not available, because that would make one request either several operations or one operation with several terminal events, and the chain accepts neither. @@ -168,7 +174,7 @@ audit_rules: chain_verification: At startup and after restart, the newest sealed segment supplies the prior chain head and the complete active segment is verified from that head. Complete retained-history verification is an out-of-band operator duty performed with evidence verify-audit across every available sealed segment and, when the writer is stopped, the active segment. Steady-state appends and readiness verify the pinned active-segment identity, modification fingerprint, expected length, verified tail, and lock-file identity without rescanning sealed history or the growing active segment. external_mutation: Any external replacement or modification of the active segment or lock file fails readiness and future appends closed for the process lifetime. Mutation within older sealed history is detected by complete out-of-band verification, not by serving-process startup or readiness. pre_authentication_and_validation_failure: Authentication, malformed-request, and invalid-selector failures remain operational-only because the core has no authenticated requester plus authorization decision from which to construct the minimal refusal event. - pseudonym_domains: [requester, actor, grant, subject] + pseudonym_domains: [requester, client, actor, grant, subject] subject_scope: key version plus operator trust domain, purpose, the binding scope, role, profile, and complete canonical selector bundle. The binding scope is the authenticated audience for an audience-scoped requirement and the holder key thumbprint for a holder-bound one; the two are domain-separated, so no binding can be read as belonging to the other mode. pseudonym_scope: Requester, actor, and grant pseudonyms for an audience-scoped operation bind the operator trust domain, requested purpose, and authenticated audience. A holder-bound operation is scoped to no relying party, so its pseudonyms bind the operator trust domain and requested purpose under a separate derivation domain, and never the holder key thumbprint. A holder-bound requester pseudonym is therefore stable across that requester's issuances within one trust domain and purpose, which is the accountability property the mode keeps and the linkability it accepts. never_record: diff --git a/products/evidence/contracts/authority-context.schema.yaml b/products/evidence/contracts/authority-context.schema.yaml index ad66dc5714..0718807eb8 100644 --- a/products/evidence/contracts/authority-context.schema.yaml +++ b/products/evidence/contracts/authority-context.schema.yaml @@ -4,11 +4,13 @@ title: Normalized Evidence authority context Version 1 description: Internal core-owned value; never accepted as an Evidence request body and never logged verbatim. type: object additionalProperties: false -required: [authenticationProfile, principal, audience, entitlements, selectorSources] +required: [authenticationProfile, principal, actorKind, audience, entitlements, selectorSources] properties: authenticationProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} principal: {type: string, minLength: 1, maxLength: 512} + actorKind: {enum: [human, agent, service]} actor: {type: string, minLength: 1, maxLength: 512} + client: {type: string, minLength: 1, maxLength: 512} requesterAttributes: type: object maxProperties: 32 @@ -39,6 +41,10 @@ $defs: authorityProfile: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} authorityKind: {enum: [statutory, organizational, consent, delegated, explicit-request]} grantId: {type: string, minLength: 1, maxLength: 512} + grantSourceIssuer: {type: string, format: uri, maxLength: 512} + grantClient: {type: string, minLength: 1, maxLength: 512} + grantResource: {type: string, minLength: 1, maxLength: 512} + grantExpiresAt: {type: integer, minimum: 1} requirement: {type: string, format: uri, maxLength: 512} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} audience: {type: string, format: uri, maxLength: 512} @@ -118,7 +124,9 @@ allOf: authorization_decision: exact_inputs: - principal + - actor kind - optional actor + - verified OAuth client - exact requirement revision - purpose - audience @@ -127,10 +135,10 @@ authorization_decision: - complete set of role, selector profile, and value origin tuples matching_rule: One entitlement must cover the complete request; permissions are never unioned across entitlements. principal_rule: Principal derives only from the one configured validated claim; a missing claim denies with no client_id, azp, header, or request fallback. - grant_rule: A grant identifier is context, never authority by possession; it must already be authenticated and bound to the matched entitlement. + grant_rule: A grant identifier is context, never authority by possession. A present task grant is accepted only for an agent and only when its immutable principal, verified client, verified resource, trusted source issuer, authority, purpose, Evidence requirement bound, and effective token/grant deadline all match. An invalid present grant cannot fall back to a standing entitlement. subject_binding_rule: Permission to serialize a response is not permission to issue under a binding mode. The one matched grant must name a holder-bound path explicitly before a holder-bound assertion may be issued on it; a grant widened to permit the SD-JWT VC serialization gains no binding-mode permission from that. Both halves are checked against the same one grant, because permissions are never unioned across grants, and the single denial that follows a failure of either does not say which one failed. selector_claim_rule: Context-derived and grant-derived selector values resolve only through the complete configured valueClaims map over the already verified token; request-derived subjects prohibit valueClaims. - authenticated_grant_rule: Values from the configured grantIdClaim and grantAuthorityClaim must both exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must cover the complete request; neither value may come from the caller request. + authenticated_grant_rule: The complete shared grant claim set must exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must cover the complete request. Selector values come only from the verified token and never the caller request. denial_order: Deny before source credential resolution or source access. privacy: raw_context_in_logs_audit_errors: prohibited diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 5db3a4b54a..93f4314347 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -325,10 +325,13 @@ allOf: authentication: properties: issuer: - pattern: ^(?:https://|http://127\.0\.0\.1:[1-9][0-9]{0,4}$) + pattern: ^(?:https://.*|http://127\.0\.0\.1:[1-9][0-9]{0,4})$ jwksUri: - pattern: >- - ^(?:https://|http://127\.0\.0\.1:[1-9][0-9]{0,4}/\.well-known/jwks\.json$) + # Local development permits a supervised issuer on the exact + # canonical loopback origin, serving its JWKS from that origin at + # any absolute path without query or fragment. The route is the + # issuer's to choose; the origin is not. + pattern: ^(?:https://.*|http://127\.0\.0\.1:[1-9][0-9]{0,4}/[^\s?#]*)$ - if: properties: assuranceProfile: @@ -477,8 +480,6 @@ $defs: - principalClaim - requesterTagsClaim - evidenceAudienceClaim - - grantIdClaim - - grantAuthorityClaim - maximumTokenLifetimeSeconds - revokedKeyIds properties: @@ -557,17 +558,12 @@ $defs: Names the verified claim that fixes the audience an assertion is issued to, and the public request cannot choose another audience. $ref: '#/$defs/claim-name' - grantIdClaim: - description: >- - Names the verified claim carrying the grant identifier, read only when a subject's - `valueOrigin` is `authenticated-grant`. - $ref: '#/$defs/claim-name' - grantAuthorityClaim: + claims: description: >- - Names the verified claim carrying the granting authority, read only when a subject's - `valueOrigin` is `authenticated-grant`; the claim value must equal the matched authority-profile - id. - $ref: '#/$defs/claim-name' + Optional direct claim-name mapping for the shared contextual-authorization profile. + Omission uses the registry_* names defined by the profile. Names remain direct JWT + members even when they contain a dot. + $ref: '#/$defs/contextual-claim-names' maximumTokenLifetimeSeconds: description: >- Caps the accepted `exp - iat` interval, and its presence requires `iat` and an `exp` @@ -595,6 +591,38 @@ $defs: Names the verified claim read as the actor alongside the principal, and omitting the key enables no fallback actor source. $ref: '#/$defs/claim-name' + allowedClients: + description: >- + Explicit machine-client admission, matched against the verified token's + `client_id`/`azp`; never `sub`. Omitting the key keeps the issuer-vouched-client + behavior. Audience plus static issuer-governed attributes alone cannot establish + that a client was granted this resource's permission, which is what + `requiredScopes` closes. + type: array + minItems: 1 + maxItems: 32 + uniqueItems: true + items: + description: One client identifier admitted to call this deployment. + type: string + minLength: 1 + maxLength: 128 + requiredScopes: + description: >- + Scopes every inbound token must carry, read from the verified token's scope set + after signature verification and before any authority claim is read. Omitting + the key keeps the no-scope-gate behavior; a missing scope is never inferred from + tags, principal, roles, `sub`, or request fields. + type: array + minItems: 1 + maxItems: 32 + uniqueItems: true + items: + description: One RFC 6749 scope-token every inbound token must carry. + type: string + minLength: 1 + maxLength: 256 + pattern: ^[\x21\x23-\x5B\x5D-\x7E]+$ audit: type: object additionalProperties: false @@ -679,6 +707,20 @@ $defs: claim-name: type: string pattern: ^[A-Za-z_][A-Za-z0-9_.-]{0,127}$ + contextual-claim-names: + type: object + additionalProperties: false + properties: + actorKind: {$ref: '#/$defs/claim-name', default: registry_actor_kind} + purpose: {$ref: '#/$defs/claim-name', default: registry_purpose} + grantId: {$ref: '#/$defs/claim-name', default: registry_grant_id} + grantAuthority: {$ref: '#/$defs/claim-name', default: registry_grant_authority} + grantSourceIssuer: {$ref: '#/$defs/claim-name', default: registry_grant_source_issuer} + grantClient: {$ref: '#/$defs/claim-name', default: registry_grant_client} + grantResource: {$ref: '#/$defs/claim-name', default: registry_grant_resource} + grantExp: {$ref: '#/$defs/claim-name', default: registry_grant_exp} + grantBounds: {$ref: '#/$defs/claim-name', default: registry_grant_bounds} + approver: {$ref: '#/$defs/claim-name', default: registry_approver} claim-path: type: string pattern: ^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$ @@ -1283,6 +1325,22 @@ $defs: minLength: 1 maxLength: 512 pattern: \S + resource: + description: >- + Optional RFC 8707 resource indicator sent as a token-request form parameter. + It is one governed absolute URI without a fragment or user information and + is transmitted exactly as configured. It names the intended resource server; + it does not retarget the token endpoint or replace the separate audience + parameter or signed client assertion audience. + type: string + format: uri + minLength: 3 + maxLength: 512 + pattern: '^[A-Za-z][A-Za-z0-9+.-]*:[A-Za-z0-9._~:/?\[\]@!$&''()*+,;=%-]+$' + not: + anyOf: + - pattern: '^[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*@' + - pattern: '%($|[^0-9A-Fa-f]|[0-9A-Fa-f]($|[^0-9A-Fa-f]))' credentialPlacement: description: >- Selects whether the client identifier and secret travel in the Authorization @@ -2105,6 +2163,20 @@ $defs: purposes, or `valueClaims` are expressed by issuing different tags rather than by naming individual clients. $ref: '#/$defs/local-id' + requesterClients: + description: >- + Exact verified OAuth clients allowed to exercise this profile's authenticated-grant + paths. Required and nonempty when any subject uses authenticated-grant. + type: array + minItems: 1 + maxItems: 32 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + grantSourceIssuer: + description: >- + Exact trusted issuer that supplied the immutable task-grant context before token + exchange. Required when any subject uses authenticated-grant. + $ref: '#/$defs/uri' grants: description: >- Complete set of authorized paths this requester class has. Permissions are never @@ -3058,10 +3130,11 @@ startup_checks: or shadowing name would read one verified token member as something the issuer wrote for another purpose. - >- - For authenticated-grant origin, verified token values at authentication.grantIdClaim and - grantAuthorityClaim must exist, grantAuthority must exactly equal the matched authority-profile - identifier, and the same entitlement must bind requirement, purpose, audience, role, selector - profile, and valueClaims result before selector resolution. + For authenticated-grant origin, the complete shared task-grant claim set must be present and + unexpired. The signed principal, client, resource, source issuer, purpose, authority, and + Evidence requirement bound must exactly match verified request context and the selected + profile before selector resolution. A present malformed grant cannot fall back to a standing + profile. - >- Required signing, audit, credential, and source dependencies are ready. - >- diff --git a/products/evidence/contracts/oid4vci-profile.yaml b/products/evidence/contracts/oid4vci-profile.yaml index eb91ffd088..ab6f4d957d 100644 --- a/products/evidence/contracts/oid4vci-profile.yaml +++ b/products/evidence/contracts/oid4vci-profile.yaml @@ -100,7 +100,7 @@ endpoints: hyphen inside pre-authorized that OpenID4VCI gives it, which is what tells a wallet holding no client registration that it may redeem a code anyway. - path: POST /offers - authorization: Mint-issued bearer token, verified as a resource server through the shared OIDC primitive + authorization: issuer-issued bearer token, verified as a resource server through the shared OIDC primitive rule: >- Adopter-facing, never wallet-facing. It accepts a credential configuration identifier, the subject selector values that identify who diff --git a/products/evidence/contracts/request-batch-audit-event.schema.yaml b/products/evidence/contracts/request-batch-audit-event.schema.yaml index a8088caee3..8826999cec 100644 --- a/products/evidence/contracts/request-batch-audit-event.schema.yaml +++ b/products/evidence/contracts/request-batch-audit-event.schema.yaml @@ -14,8 +14,10 @@ required: - bundleRevision - purpose - requesterPseudonym + - actorKind - responseProtection - decision + - reason - durationMilliseconds properties: schema: {const: registry.evidence.audit.request-batch/v1} @@ -28,9 +30,12 @@ properties: bundleRevision: {type: string, pattern: '^sha256:[a-f0-9]{64}$'} purpose: {type: string, pattern: '^[a-z][a-z0-9._:-]{0,127}$'} requesterPseudonym: {$ref: '#/$defs/pseudonym'} + actorKind: {enum: [human, agent, service]} + clientPseudonym: {$ref: '#/$defs/pseudonym'} actorPseudonym: {$ref: '#/$defs/pseudonym'} responseProtection: {const: signed} decision: {enum: [authorized, released, aborted]} + reason: {const: authorization.allowed} sourceId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} adapterId: {type: string, pattern: '^[a-z][a-z0-9._-]{0,127}$'} itemIndices: {$ref: '#/$defs/item-indices'} diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 9b198fe705..78334497aa 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -18,14 +18,14 @@ invariants: enforcement: Startup bundle-combination validation and explicit operator review of one atomic revision. negative_test: sec-unsafe-definition-combination - id: V1-I04 - rule: Principals and attributes derive only from configured validated authentication sources; missing data denies. + rule: Principals, actor kind, and attributes derive only from configured validated authentication sources; missing data denies. threat: Authentication bypass through fallback claims or unsigned headers. - enforcement: Strict authentication profile and one configured principal claim with no fallback. + enforcement: Strict authentication profile, one configured principal claim, and one strict shared actor-kind claim with no fallback. negative_test: sec-missing-principal-no-fallback - id: V1-I05 - rule: One authorization decision binds requester, optional actor, requirement revision, purpose, all role/profile/origin tuples, authority, and audience. + rule: One authorization decision binds requester, actor kind, optional actor identity, verified client, requirement revision, purpose, all role/profile/origin tuples, authority, and audience. A task-grant path additionally binds immutable grant id, principal, original client and resource, trusted source issuer, deadline, and exact Evidence requirement bound. threat: Permission splicing or confused-deputy access across partially authorized dimensions. - enforcement: Exact complete-entitlement match before authorized-material audit, credential resolution, or source access. A refusal after successful authentication follows the separate minimal denial-audit invariant. + enforcement: Exact complete-entitlement match before authorized-material audit, credential resolution, or source access. A present invalid grant cannot fall back to a standing profile. A refusal after successful authentication follows the separate minimal denial-audit invariant. negative_test: sec-no-entitlement-union - id: V1-I06 rule: Selector profiles and values are provider-lookup inputs, never proof of authority. @@ -35,7 +35,7 @@ invariants: - id: V1-I07 rule: Caller-provided consent, approval, or grant references never create authority. threat: Fabricated reference escalates authority. - enforcement: Grant identifiers are accepted only from authenticated context and must be bound to the complete entitlement. + enforcement: Grant identifiers are accepted only inside the complete verified shared grant context. Agent kind, principal, client, resource, trusted source issuer, deadline, authority, purpose, and requirement bound must match the complete entitlement. negative_test: sec-caller-grant-reference-rejected - id: V1-I08 rule: Callers cannot choose selector fields, operators, weights, thresholds, normalization, or query plans. @@ -45,7 +45,7 @@ invariants: - id: V1-I09 rule: Source calls are fixed by trusted configuration and executed only by the core. threat: SSRF, credential forwarding, overbroad reads, or script-directed networking. - enforcement: Generic fixed HTTPS executor owns the closed source sequence the requirement's acquisition kind fixes, origin, method, fixed or tagged selector/prior-fact-bound path, fixed headers, authentication, TLS, projection, proxy denial, bounded source and OAuth single-flight admission, limits, and one request per configured stage. Explicit named connections share only process-local transport, admission and credential state; startup rejects differing resolved connection values and no secret-byte deduplication occurs. Rhai returns only bounded query pairs and one JSON body, validated before credentials. + enforcement: Generic fixed HTTPS executor owns the closed source sequence the requirement's acquisition kind fixes, origin, method, fixed or tagged selector/prior-fact-bound path, fixed headers, authentication, governed OAuth resource indicator, TLS, projection, proxy denial, bounded source and OAuth single-flight admission, limits, and one request per configured stage. Explicit named connections share only process-local transport, admission and credential state; startup rejects differing resolved connection values and no secret-byte deduplication occurs. Rhai returns only bounded query pairs and one JSON body, validated before credentials. negative_test: sec-source-request-immutable - id: V1-I10 rule: Provider lookup has only match, no_match, and ambiguous; Evidence never exposes or chooses candidates, while reviewed derivation may compare only declared authorized selectors with complete facts from one unique record. @@ -183,9 +183,9 @@ invariants: enforcement: Bundle validation binds source authentication kind none to assuranceProfile local, a canonical loopback HTTP origin with an explicit non-zero port, no TLS trust profile, and the core executor's header-free authentication plan; production and evidence-grade reject the kind. negative_test: sec-local-source-none-boundary - id: V1-I37 - rule: Each configured authority claim names a distinct token member, and none names a registered JWT claim; only principalClaim may name sub. + rule: Each configured product or shared contextual-authorization claim names a distinct token member, and none names a registered JWT claim; only principalClaim may name sub. threat: One verified member is read as two different authorities, or an authority is read from a member the issuer wrote for another purpose, such as a grant authority taken from the audience Evidence validates against itself. - enforcement: Authentication validation refuses repeated names and the registered-claim set at startup, on the resource server, so the rule holds for every configured OIDC issuer rather than only for issuers that refuse to mint such tokens. + enforcement: Authentication validation refuses repeated names across both claim sets and the registered-claim set at startup, on the resource server, so the rule holds for every configured OIDC issuer rather than only for issuers that refuse to mint such tokens. negative_test: sec-authority-claim-names-closed - id: V1-I38 rule: Every selector set an authority grant can activate carries each role and profile the source path template binds. @@ -195,7 +195,7 @@ invariants: - id: V1-I39 rule: Every authorization refusal after successful authentication is durably recorded as one standalone minimal native event before the generic 403 is returned; an audit failure returns the generic 503 instead. threat: An authenticated caller can probe denied operations without an accountable record, or the refusal record leaks untrusted request, unmatched-authority, selector, or response-format material. - enforcement: The core derives requester and optional actor pseudonyms scoped by operator trust domain, requested purpose, and authenticated audience, while request-rate accounting remains principal-scoped. It constructs the closed refusal branch under its distinct registry.evidence.audit.authorization-refusal/v1 discriminator with operation, assurance profile, bundle revision, denial category and decision, timestamp and duration, durably appends it before the response, and omits every request-derived, authority, selector, response-protection, source, and evaluation field. Authentication, malformed-request, and invalid-selector failures remain operational-only. + enforcement: The core derives requester plus available client, grant, and actor pseudonyms scoped by operator trust domain, requested purpose, and authenticated audience, while request-rate accounting remains principal-scoped. It records actor kind and a fixed safe reason, constructs the closed refusal branch under its distinct registry.evidence.audit.authorization-refusal/v1 discriminator, durably appends it before the response, and omits every request-derived requirement, purpose, unmatched authority, selector, response-protection, source, and evaluation field. Authentication, malformed-request, and invalid-selector failures remain operational-only. negative_test: sec-authenticated-authorization-refusal-audited - id: V1-I40 rule: A search-then-fetch acquisition is exactly one fixed search and, only after a unique schema-valid match, one fixed fetch; only the validated search FactSet may cross the stage boundary and no response or script can select transport or create a third call. @@ -267,6 +267,12 @@ invariants: threat: An unauthenticated discovery response leaks source bindings, selector or adapter configuration, credentials, authorization policy, signing material, audit state, internal network information, or assertion data. enforcement: Evidence compilation renders the shared closed Registry Discovery profile from an explicit public allowlist and seals catalog.jsonld in the deployment package. It emits one Data Service node per exact Evidence Type and compatible binding-and-response profile, with a distinct centrally derived bindingId, so JSON-LD, RDF, and multi-filter search cannot create a cross-requirement capability pair. Startup recomputes only to reject a missing, extra, or byte-drifted artifact, then captures the packaged bytes. Capability identifiers encode compatible binding-and-response tuples, never independently combinable tags. The stable public route serves only the captured packaged bytes without authentication, source access, assertion evaluation, signing, or Evidence audit writes. negative_test: sec-provider-public-projection + - id: V1-I54 + rule: When authentication states allowedClients or requiredScopes, admission is the explicit list and the required scope set of the verified token; audience plus static issuer-governed attributes alone never establish resource permission, and a missing scope is never inferred from tags, principal, roles, sub, or request fields. + threat: An issuer that returns a correctly signed token for a known resource with zero scopes while still emitting the client's static attributes lets that client reach protected Evidence operations on audience and attributes alone. + enforcement: Client admission runs in the platform verifier's client_id/azp semantics; the scope gate runs after signature verification and before any authority claim is read, with the same closed refusal a refused client gets. + negative_test: sec-admission-clients-and-scopes + cross_cutting: holder_bound_entity_reference_prohibited: threat: An entity reference is a pointer only the one relying party it was scoped to can resolve. A holder-bound assertion has no such party, so disclosing one would emit an identifier nobody who receives the credential can resolve and that correlates its holder wherever it is presented. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index fdebd6938e..e7bd9decb5 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -15,11 +15,15 @@ entries: - id: sec-assurance-profile-fixture-boundary tests: - {file: crates/registry-evidence/src/config.rs, name: assurance_profile_is_explicit_and_strict_profiles_require_fixtures} - - {file: crates/registry-evidence/src/config.rs, name: only_local_assurance_accepts_the_exact_loopback_mint_identity} - - {file: crates/registry-evidence/src/auth.rs, name: jwks_fetch_policy_opens_http_only_for_exact_local_mint} + - {file: crates/registry-evidence/src/config.rs, name: only_local_assurance_accepts_a_canonical_loopback_issuer_with_same_origin_jwks} + - {file: crates/registry-evidence/src/auth.rs, name: jwks_fetch_policy_opens_http_only_for_the_supervised_local_issuer} - {file: crates/registry-evidence/src/bundle.rs, name: local_bundle_may_omit_fixtures_but_strict_bundles_remain_complete} - {file: crates/registry-evidence/src/bundle.rs, name: strict_assurance_rejects_partial_fixture_suites} - {file: crates/registry-evidence/src/runtime_tests.rs, name: local_runtime_prepares_a_bearer_free_procedure_and_keeps_the_real_security_path} + - id: sec-admission-clients-and-scopes + tests: + - {file: crates/registry-evidence/src/config.rs, name: admission_lists_are_optional_but_present_means_nonempty_and_bounded} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: admission_gates_refuse_unadmitted_clients_and_missing_scopes} - id: expected-assurance-profile-mismatch tests: - {file: crates/registry-evidence-verifier/src/verifier.rs, name: authentic_local_assertions_fail_deployable_assurance_expectations} @@ -41,7 +45,10 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: a_configured_metrics_listener_serves_beside_the_evidence_listener} - {file: crates/registry-evidence/src/config.rs, name: the_optional_metrics_listener_is_absent_by_default_and_stays_operator_private} - id: sec-no-entitlement-union - tests: [{file: crates/registry-evidence/src/config.rs, name: complete_authority_paths_cannot_be_unioned_across_partial_grants}] + tests: + - {file: crates/registry-evidence/src/config.rs, name: complete_authority_paths_cannot_be_unioned_across_partial_grants} + - {file: crates/registry-evidence/src/config.rs, name: task_grant_profiles_bind_trusted_source_and_verified_client} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: task_grant_context_is_bound_before_selector_or_source_access} - id: sec-authenticated-authorization-refusal-audited tests: - {file: crates/registry-evidence/src/audit.rs, name: audit_contract_schema_accepts_each_native_shape_and_rejects_mixed_shapes} @@ -75,6 +82,8 @@ entries: - {file: crates/registry-evidence/tests/source_contracts.rs, name: compilation_refuses_an_authorization_scheme_that_is_not_an_http_token} - {file: crates/registry-evidence/tests/source_contracts.rs, name: a_private_key_jwt_source_sends_an_endpoint_audienced_assertion_and_no_secret} - {file: crates/registry-evidence/tests/source_contracts.rs, name: a_private_key_jwt_source_signs_the_configured_assertion_audience} + - {file: crates/registry-evidence/src/config.rs, name: oauth_resource_indicator_is_optional_and_rejects_relative_or_ambiguous_values} + - {file: crates/registry-evidence/tests/source_contracts.rs, name: oauth_resource_indicator_rejects_unusable_identifiers_before_credentials} - id: sec-candidate-material-rejected tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: one_runtime_proves_all_definitions_and_collapses_unresolved_relationships}] - id: sec-rhai-output-shape-closed diff --git a/products/evidence/contracts/selector-contract.yaml b/products/evidence/contracts/selector-contract.yaml index 0b53845405..5a629cd7ed 100644 --- a/products/evidence/contracts/selector-contract.yaml +++ b/products/evidence/contracts/selector-contract.yaml @@ -23,7 +23,7 @@ profile: rule: Each authority path permits exactly the reviewed origin for each role and profile. value_claims: authenticated-context: Required exact field-name to verified-token claim-path map. - authenticated-grant: Required exact field-name to verified-token claim-path map plus configured grantIdClaim and grantAuthorityClaim; grantAuthority must equal the matched authority-profile identifier. + authenticated-grant: Required exact field-name to verified-token claim-path map plus a complete shared task grant. The signed principal, client, resource, source issuer, authority, purpose, Evidence requirement bound, and effective deadline must match the verified request and profile. request: Prohibited; selector values come only from the closed public request values object. completeness: Map keys must exactly equal the named profile field set; missing, extra, duplicate-target, or invalid claim paths fail startup. claim_path_pattern: '^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z_][A-Za-z0-9_-]*)*$' diff --git a/products/evidence/contracts/source-contract.yaml b/products/evidence/contracts/source-contract.yaml index b0ab1aa1eb..dbe59b01f5 100644 --- a/products/evidence/contracts/source-contract.yaml +++ b/products/evidence/contracts/source-contract.yaml @@ -200,10 +200,10 @@ authentication: placement: The exact allowlisted provider header. rule: Header name is validated against the same collision denylist as fixed headers and cannot be Authorization. oauth2-client-credentials: - inputs: [token_endpoint, client_id_secret_ref, optional_client_secret_ref, optional_client_assertion_key_secret_ref, optional_fixed_client_assertion_audience, optional_fixed_scope, optional_fixed_audience, optional_credential_placement, maximum_cache_lifetime, optional_assumed_token_lifetime] + inputs: [token_endpoint, client_id_secret_ref, optional_client_secret_ref, optional_client_assertion_key_secret_ref, optional_fixed_client_assertion_audience, optional_fixed_scope, optional_fixed_audience, optional_fixed_resource, optional_credential_placement, maximum_cache_lifetime, optional_assumed_token_lifetime] rules: - Token acquisition is credential bootstrap, not an evidence-data request or fact source. - - Endpoint, grant, client authentication form, scope, audience, placement, response bounds, redirect denial, and cache maximum are fixed. + - Endpoint, grant, client authentication form, scope, audience, resource, placement, response bounds, redirect denial, and cache maximum are fixed. - Waiting for the per-source token-cache and single-flight boundary is limited by the configured source timeout. - Cache lifetime is clamped to provider expiry and the configured maximum. - Token request and response are unavailable to Rhai and fully redacted. @@ -215,6 +215,7 @@ authentication: credential_placements: [basic-header, form-body] credential_placement_rule: RFC 6749 section 2.3.1 defines Basic authentication and the request-body parameters and requires that those parameters never appear in the request URI. Version 1 offers no query-string placement, so no client identifier or secret can reach an authorization-server, proxy, or ingress URL log. The token URL, body, response, and debug output are still fully redacted. audience_rule: An authorization server that scopes a token to a named API reads a fixed audience token-request parameter, without which it returns a token the source cannot accept. The audience is a governed bundle string, never derived from a request or a response, and it is sent only when the bundle states it. + resource_rule: RFC 8707 resource is a separate optional token-request form parameter naming one intended resource server. It is a governed absolute URI without a fragment or user information, preserved byte for byte and never derived from a caller, adapter, or response. It is not the client assertion audience, not an outbound destination, and not a replacement for the existing provider-specific audience parameter. An absent resource preserves the previous form exactly. token_endpoint_transport: The same HTTPS-or-explicit-numeric-loopback rule as the evidence-data origin. secret_rules: - The governed bundle contains only secret:file logical references. Environment-variable interpolation and literal secret values are not supported. diff --git a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml index 4fc0017ea9..faa1e7cc5f 100644 --- a/products/evidence/fixtures/acceptance/adult-status/evidence.yaml +++ b/products/evidence/fixtures/acceptance/adult-status/evidence.yaml @@ -13,8 +13,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} diff --git a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml index 450918a06f..7368675d53 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/evidence.yaml @@ -10,14 +10,13 @@ authentication: kind: oidc-access-token issuer: https://identity.invalid audiences: [evidence-fixture] + allowedClients: [evidence-task-agent] tokenTypes: [at+jwt] algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} @@ -172,6 +171,8 @@ sources: authorityProfiles: statutory-caseworker-v1: + requesterClients: [evidence-task-agent] + grantSourceIssuer: https://casework.invalid kind: statutory requesterTags: [fixture-agency] grants: diff --git a/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml index 2455576b83..0163d809ec 100644 --- a/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml +++ b/products/evidence/fixtures/acceptance/all-definitions/fixtures/legal-parent-relationship-cases.yaml @@ -23,8 +23,6 @@ common: child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} verified_token_claims: - evidence_grant_id: synthetic-parentage-grant-001 - evidence_authority: statutory-caseworker-v1 grant: {candidate_parent: {person_reference: synthetic-parent-reference-001}} expectedRequestParts: query: [] @@ -32,7 +30,7 @@ common: expectedTransport: {path: /v1/child-relationships, fixedHeaders: [{name: Accept, value: application/json}]} cases: - {id: positive, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} - - {id: negative-false-is-success, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-false-is-success, verified_token_claims: {grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} - {id: boundary-correct-role-order, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true, expected_subject_roles: [child, candidate-parent]} - {id: negative-swapped-roles, subjects: [{role: candidate-parent, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: child, profile: person-reference-v1}], expected: pre-source-selector-rejection} - {id: negative-caller-candidate-substitution, subjects: [{role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: candidate-parent, profile: person-reference-v1, values: {person_reference: synthetic-substitute-reference}}], expected: pre-source-selector-rejection} @@ -40,7 +38,7 @@ cases: - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-child-unresolved, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} diff --git a/products/evidence/fixtures/acceptance/holder-bound/evidence.yaml b/products/evidence/fixtures/acceptance/holder-bound/evidence.yaml index 158d9dfd8e..7d8f01cf27 100644 --- a/products/evidence/fixtures/acceptance/holder-bound/evidence.yaml +++ b/products/evidence/fixtures/acceptance/holder-bound/evidence.yaml @@ -27,14 +27,13 @@ authentication: kind: oidc-access-token issuer: https://identity.invalid audiences: [evidence-fixture] + allowedClients: [evidence-task-agent] tokenTypes: [at+jwt] algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} @@ -191,6 +190,8 @@ sources: authorityProfiles: statutory-caseworker-v1: + requesterClients: [evidence-task-agent] + grantSourceIssuer: https://casework.invalid kind: statutory requesterTags: [fixture-agency] grants: diff --git a/products/evidence/fixtures/acceptance/holder-bound/fixtures/legal-parent-relationship-cases.yaml b/products/evidence/fixtures/acceptance/holder-bound/fixtures/legal-parent-relationship-cases.yaml index 2455576b83..0163d809ec 100644 --- a/products/evidence/fixtures/acceptance/holder-bound/fixtures/legal-parent-relationship-cases.yaml +++ b/products/evidence/fixtures/acceptance/holder-bound/fixtures/legal-parent-relationship-cases.yaml @@ -23,8 +23,6 @@ common: child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} verified_token_claims: - evidence_grant_id: synthetic-parentage-grant-001 - evidence_authority: statutory-caseworker-v1 grant: {candidate_parent: {person_reference: synthetic-parent-reference-001}} expectedRequestParts: query: [] @@ -32,7 +30,7 @@ common: expectedTransport: {path: /v1/child-relationships, fixedHeaders: [{name: Accept, value: application/json}]} cases: - {id: positive, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true} - - {id: negative-false-is-success, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-false-is-success, verified_token_claims: {grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001, synthetic-parent-reference-002], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} - {id: boundary-correct-role-order, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: true, expected_lookup: match, derivation_runs: true, signed_success: true, expected_subject_roles: [child, candidate-parent]} - {id: negative-swapped-roles, subjects: [{role: candidate-parent, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: child, profile: person-reference-v1}], expected: pre-source-selector-rejection} - {id: negative-caller-candidate-substitution, subjects: [{role: child, profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, {role: candidate-parent, profile: person-reference-v1, values: {person_reference: synthetic-substitute-reference}}], expected: pre-source-selector-rejection} @@ -40,7 +38,7 @@ cases: - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-child-unresolved, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml index 6dca49ab0e..7be9686e97 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/evidence.yaml @@ -3,7 +3,7 @@ assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, publicOrigin: https://evidence.invalid, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} publication: {serviceId: urn:example:fixture:service:evidence, title: Synthetic Evidence service, description: Synthetic minimum-disclosure assertions for acceptance testing, endpointUrl: https://evidence.example.invalid, jurisdictions: [urn:example:jurisdiction:acceptance]} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], allowedClients: [evidence-task-agent], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} @@ -55,6 +55,8 @@ sources: factSchema: schemas/facts.schema.yaml authorityProfiles: statutory-caseworker-v1: + requesterClients: [evidence-task-agent] + grantSourceIssuer: https://casework.invalid kind: statutory requesterTags: [fixture-agency] grants: diff --git a/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml index 9e65046256..da4be65a35 100644 --- a/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml +++ b/products/evidence/fixtures/acceptance/legal-parent-relationship/fixtures/cases.yaml @@ -23,8 +23,6 @@ common: child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}} candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-parent-reference-001}} verified_token_claims: - evidence_grant_id: synthetic-parentage-grant-001 - evidence_authority: statutory-caseworker-v1 grant: candidate_parent: {person_reference: synthetic-parent-reference-001} expectedRequestParts: @@ -45,8 +43,6 @@ cases: signed_success: true - id: negative-false-is-success verified_token_claims: - evidence_grant_id: synthetic-parentage-grant-001 - evidence_authority: statutory-caseworker-v1 grant: candidate_parent: {person_reference: synthetic-non-parent-reference-003} derivationSelectorInputs: @@ -78,7 +74,7 @@ cases: - {id: missing-record, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: no-match, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-child-unresolved, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {evidence_grant_id: synthetic-parentage-grant-001, evidence_authority: statutory-caseworker-v1, grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} + - {id: negative-candidate-unresolved-or-typo, verified_token_claims: {grant: {candidate_parent: {person_reference: synthetic-non-parent-reference-003}}}, derivationSelectorInputs: {child: {profile: civil-record-reference-v1, values: {record_reference: synthetic-child-record-001}}, candidate-parent: {profile: person-reference-v1, values: {person_reference: synthetic-non-parent-reference-003}}}, source: {total: 1, records: [{returned_child_reference: synthetic-child-record-001, parent_references: [synthetic-parent-reference-001], reference_namespace: urn:example:fixture:person-reference, relationship_set_contract: urn:example:fixture:legal-parent-set:v1, relationship_set_complete: true}]}, expected_value: false, expected_lookup: match, derivation_runs: true, signed_success: true} - {id: negative-raw-record-search-zero-not-false, source: {total: 0, records: []}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - {id: negative-role-resolution-ambiguous, source: {total: 2, records: [{}, {}]}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} diff --git a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml index 5b60b972ef..f9d3766fd9 100644 --- a/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml +++ b/products/evidence/fixtures/acceptance/professional-licence/evidence.yaml @@ -3,7 +3,7 @@ assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, publicOrigin: https://evidence.invalid, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} publication: {serviceId: urn:example:fixture:service:evidence, title: Synthetic Evidence service, description: Synthetic minimum-disclosure assertions for acceptance testing, endpointUrl: https://evidence.example.invalid, jurisdictions: [urn:example:jurisdiction:acceptance]} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} diff --git a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml index 50380d93d1..d154365125 100644 --- a/products/evidence/fixtures/acceptance/residence-region/evidence.yaml +++ b/products/evidence/fixtures/acceptance/residence-region/evidence.yaml @@ -3,7 +3,7 @@ assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, publicOrigin: https://evidence.invalid, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} publication: {serviceId: urn:example:fixture:service:evidence, title: Synthetic Evidence service, description: Synthetic minimum-disclosure assertions for acceptance testing, endpointUrl: https://evidence.example.invalid, jurisdictions: [urn:example:jurisdiction:acceptance]} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} diff --git a/products/evidence/fixtures/acceptance/surviving-spouse-status/evidence.yaml b/products/evidence/fixtures/acceptance/surviving-spouse-status/evidence.yaml index 5c0c8ed344..d9e0dc7789 100644 --- a/products/evidence/fixtures/acceptance/surviving-spouse-status/evidence.yaml +++ b/products/evidence/fixtures/acceptance/surviving-spouse-status/evidence.yaml @@ -3,7 +3,7 @@ assuranceProfile: evidence-grade service: {providerId: urn:example:fixture:provider:evidence, publicOrigin: https://evidence.invalid, trustDomain: urn:example:fixture:trust-domain:acceptance} issuer: {id: urn:example:fixture:issuer:authority} publication: {serviceId: urn:example:fixture:service:evidence, title: Synthetic Evidence service, description: Synthetic minimum-disclosure assertions for acceptance testing, endpointUrl: https://evidence.example.invalid, jurisdictions: [urn:example:jurisdiction:acceptance]} -authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, grantIdClaim: evidence_grant_id, grantAuthorityClaim: evidence_authority, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} +authentication: {kind: oidc-access-token, issuer: https://identity.invalid, audiences: [evidence-fixture], tokenTypes: [at+jwt], algorithms: [ES256], jwksUri: https://identity.invalid/.well-known/jwks.json, principalClaim: sub, requesterTagsClaim: evidence_tags, evidenceAudienceClaim: evidence_audience, maximumTokenLifetimeSeconds: 300, revokedKeyIds: []} audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} diff --git a/products/evidence/fixtures/conformance/audit-events.yaml b/products/evidence/fixtures/conformance/audit-events.yaml index a2586b1273..e2b75ebe0c 100644 --- a/products/evidence/fixtures/conformance/audit-events.yaml +++ b/products/evidence/fixtures/conformance/audit-events.yaml @@ -10,6 +10,7 @@ access_attempt: requirement: urn:example:fixture:requirement:property:v1 bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa purpose: fixture-procedure + actorKind: service requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 authority: {kind: statutory} subjects: @@ -20,6 +21,7 @@ access_attempt: sourceId: source-a adapterId: adapter-a decision: authorized + reason: authorization.allowed durationMilliseconds: 2 disclosure_release: schema: registry.evidence.audit/v1 @@ -31,6 +33,7 @@ disclosure_release: requirement: urn:example:fixture:requirement:property:v1 bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa purpose: fixture-procedure + actorKind: service requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 authority: {kind: statutory} subjects: @@ -41,6 +44,7 @@ disclosure_release: sourceId: source-a adapterId: adapter-a decision: released + reason: authorization.allowed disclosedConcepts: [urn:example:fixture:concept:boolean-a] evidenceId: urn:example:fixture:evidence:001 signingKeyId: _QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo @@ -55,6 +59,7 @@ unsigned_disclosure_release: requirement: urn:example:fixture:requirement:property:v1 bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa purpose: fixture-procedure + actorKind: service requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 authority: {kind: statutory} subjects: @@ -65,6 +70,7 @@ unsigned_disclosure_release: sourceId: source-a adapterId: adapter-a decision: released + reason: authorization.allowed disclosedConcepts: [urn:example:fixture:concept:boolean-a] evidenceId: urn:example:fixture:evidence:001 durationMilliseconds: 12 @@ -78,6 +84,7 @@ disclosure_release_fetch_set: requirement: urn:example:fixture:requirement:property:v1 bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa purpose: fixture-procedure + actorKind: service requesterPseudonym: hmac-sha256:v1:1111111111111111111111111111111111111111111111111111111111111111 authority: {kind: statutory} subjects: @@ -90,6 +97,7 @@ disclosure_release_fetch_set: sourceIds: [source-a, source-b, source-c] adapterIds: [adapter-a, adapter-b, adapter-b] decision: released + reason: authorization.allowed disclosedConcepts: [urn:example:fixture:concept:boolean-a] evidenceId: urn:example:fixture:evidence:002 signingKeyId: _QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo @@ -102,9 +110,11 @@ authorization_refusal: operation: fixture-operation-00000002 phase: denial bundleRevision: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + actorKind: service requesterPseudonym: hmac-sha256:v1:3333333333333333333333333333333333333333333333333333333333333333 actorPseudonym: hmac-sha256:v1:4444444444444444444444444444444444444444444444444444444444444444 decision: not-authorized + reason: authorization.profile safeErrorCategory: not-authorized durationMilliseconds: 3 negative: diff --git a/products/evidence/fixtures/conformance/selector-matrix.yaml b/products/evidence/fixtures/conformance/selector-matrix.yaml index ee9a21692c..bb6cdd4c74 100644 --- a/products/evidence/fixtures/conformance/selector-matrix.yaml +++ b/products/evidence/fixtures/conformance/selector-matrix.yaml @@ -64,8 +64,6 @@ profiles: audience: urn:example:fixture:audience:requester-a exact_role_profile_origin: [subject, demographics-with-event-v1, authenticated-grant] configured_grant_claims: - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority verified_grant_binding: grant_id: synthetic-grant-001 grant_authority: urn:example:fixture:authority:grant-a diff --git a/products/evidence/fixtures/conformance/selectors/evidence.yaml b/products/evidence/fixtures/conformance/selectors/evidence.yaml index 2117820bfd..09eb221427 100644 --- a/products/evidence/fixtures/conformance/selectors/evidence.yaml +++ b/products/evidence/fixtures/conformance/selectors/evidence.yaml @@ -9,14 +9,13 @@ authentication: kind: oidc-access-token issuer: https://identity.invalid audiences: [selector-conformance] + allowedClients: [evidence-task-agent] tokenTypes: [at+jwt] algorithms: [ES256] jwksUri: https://identity.invalid/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: @@ -238,6 +237,8 @@ authorityProfiles: family_name: identity.family_name birth_date: identity.birth_date authenticated-grant-v1: + requesterClients: [evidence-task-agent] + grantSourceIssuer: https://casework.invalid kind: consent requesterTags: [selector-reviewer] grants: diff --git a/products/evidence/fixtures/conformance/supported-values/evidence.yaml b/products/evidence/fixtures/conformance/supported-values/evidence.yaml index b2d7346d47..3930551018 100644 --- a/products/evidence/fixtures/conformance/supported-values/evidence.yaml +++ b/products/evidence/fixtures/conformance/supported-values/evidence.yaml @@ -15,8 +15,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: diff --git a/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml index b3b4f9a6f6..dae1b7a5d6 100644 --- a/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml +++ b/products/evidence/reference/deployment-targets/environments/local/evidence/governance.yaml @@ -13,8 +13,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} diff --git a/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml index 53da63edce..95c4559c79 100644 --- a/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml +++ b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml @@ -13,8 +13,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} diff --git a/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml index d9bc071623..fa4b317630 100644 --- a/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml +++ b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml @@ -13,8 +13,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: secret:file/evidence-audit-hmac, hashKeyVersion: 1, failClosed: true} diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index fa662b353f..0d6f7178b7 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -146,9 +146,11 @@ the provider contract is still being written. This does not disable any other runtime boundary: the bundle and runtime remain immutable, inbound authentication and authorization remain required, source requests remain fixed and bounded, signing and both audit gates remain fail-closed, and assertions -are visibly marked `local`. Local Mint may use only the exact canonical issuer origin -`http://127.0.0.1:` and the same origin's -`/.well-known/jwks.json`; other authentication URLs remain HTTPS. +are visibly marked `local`. A supervised local issuer may use only the exact +canonical issuer origin `http://127.0.0.1:`, and its JWKS must +be served from that exact origin at an absolute path with no query or fragment; +other authentication URLs remain HTTPS. The route is the issuer's to choose; +the origin is not. `production` and `evidence-grade` are deployable profiles. Every requirement must reference a fixture suite, and the existing complete coverage validation @@ -171,9 +173,11 @@ artifact, or alternate evaluator is introduced by the assurance profile. | `authentication.principalClaim` | yes | The only claim used for the principal. Its absence denies; `client_id`, `azp`, request data, and proxy headers are not fallbacks. | | `authentication.requesterTagsClaim` | yes | Claim containing the requester tags matched against an authority profile. | | `authentication.evidenceAudienceClaim` | yes | Claim containing the exact evidence audience. The public request cannot choose another audience. | -| `authentication.grantIdClaim`, `authentication.grantAuthorityClaim` | yes | Claims used only when an `authenticated-grant` origin is selected. The authority must equal the matched authority-profile id. | +| `authentication.claims` | no | Direct claim-name mapping for shared actor, purpose, grant id, authority, source issuer, client, resource, expiration, bounds, and approver claims. Omission uses the documented `registry_*` names. The names must be distinct from each other, Evidence product claims, and registered authentication claims. | | `authentication.maximumTokenLifetimeSeconds` | yes | Positive maximum accepted `exp - iat`, up to 86,400 seconds. Its presence requires `iat`, `exp > iat`, and an interval within the maximum. | | `authentication.revokedKeyIds` | yes | Explicit emergency denylist, including an empty list. It is checked before cached JWKS key selection. | +| `authentication.allowedClients` | no | Explicit machine-client admission, matched against the verified token's `client_id`/`azp` and never `sub`. Omission keeps the issuer-vouched-client behavior. A stated list must be non-empty, unique, and bounded (at most 32 entries of 1..=128 bytes). Audience plus static issuer-governed attributes alone cannot establish that a client was granted this resource's permission, which is what `requiredScopes` closes. | +| `authentication.requiredScopes` | no | Scopes every inbound token must carry, read from the verified token's scope set after signature verification and before any authority claim is read. Omission keeps the no-scope-gate behavior. A stated list must be non-empty, unique RFC 6749 scope-tokens (at most 32 entries of 1..=256 bytes). A missing scope is never inferred from tags, principal, roles, `sub`, or request fields. | | `authentication.actorClaim` | no | Optional verified actor claim. Omission does not enable a fallback actor source. | ### Audit, subject binding, rates, and signing @@ -236,8 +240,15 @@ Every grant subject fixes `role`, `selectorProfile`, and one `valueOrigin`: - `authenticated-context` requires an exact field-to-verified-claim `valueClaims` map and rejects caller values; and - `authenticated-grant` requires the same exact map, rejects caller values, - and additionally requires the configured grant id and grant authority. The - authenticated authority value must equal the matched authority-profile id. + and additionally requires the complete shared task-grant claims. The actor + must be an agent. The signed principal, original client and resource, trusted + source issuer, authority, purpose, Evidence requirement bound, and effective + token/grant deadline must all match. A malformed or mismatched present grant + cannot fall back to a standing profile. + +An authority profile containing an `authenticated-grant` subject must declare +nonempty `requesterClients` and one exact `grantSourceIssuer`. Every requester +client must also appear in `authentication.allowedClients`. Claim paths are resolved only from the strictly verified access token. A caller-supplied grant reference, selector, consent reference, or approval value @@ -457,6 +468,7 @@ sourceConnections: clientAssertionKeyRef: secret:file/workload-client-key clientAssertionAudience: https://identity.gov.example/client-auth audience: https://registry.gov.example + resource: https://registry.gov.example maximumCacheSeconds: 60 concurrencyLimit: 4 admissionTimeoutMilliseconds: 5000 @@ -466,8 +478,8 @@ sourceConnections: The authentication object is the complete existing union documented below: local `none`, Basic, static authorization, static API key, or OAuth client credentials using a shared secret or signed client assertion. Resource -`audience` and `clientAssertionAudience` retain their distinct meanings. The -logical `tlsTrustProfile` retains its additive runtime CA binding. +`audience`, `resource`, and `clientAssertionAudience` retain their distinct +meanings. The logical `tlsTrustProfile` retains its additive runtime CA binding. | Connection key | Required | Meaning | |---|---|---| @@ -555,6 +567,7 @@ authentication: clientAssertionAudience: https://auth.registry.gov.example/ scope: recordsearch audience: https://api.registry.gov.example/ + resource: https://api.registry.gov.example/ maximumCacheSeconds: 300 ``` @@ -619,6 +632,14 @@ it returns a token the source will reject. It is not one is a claim inside the signed assertion, and a server may want both, neither, or different values for each. +`resource` is the optional RFC 8707 resource indicator. It is a separate +token-request form field naming one intended resource server as an absolute URI +without a fragment or user information. Evidence sends the exact configured +string and never derives it from a caller, adapter, source response, or the +source URL. Use it when the authorization server supports resource indicators; +the existing `audience` parameter remains available for providers that require +it. Neither field changes where the token request is sent. + RFC 6749 section 5.1 makes `expires_in` recommended rather than required, so a compliant provider may return only `access_token` and `token_type`. A token response omitting `expires_in` is a credential failure unless @@ -1484,17 +1505,30 @@ authentication authentication.actorClaim authentication.algorithms authentication.algorithms[] +authentication.allowedClients +authentication.allowedClients[] authentication.audiences authentication.audiences[] authentication.evidenceAudienceClaim -authentication.grantAuthorityClaim -authentication.grantIdClaim +authentication.claims +authentication.claims.actorKind +authentication.claims.approver +authentication.claims.grantAuthority +authentication.claims.grantBounds +authentication.claims.grantClient +authentication.claims.grantExp +authentication.claims.grantId +authentication.claims.grantResource +authentication.claims.grantSourceIssuer +authentication.claims.purpose authentication.issuer authentication.jwksUri authentication.kind authentication.maximumTokenLifetimeSeconds authentication.principalClaim authentication.requesterTagsClaim +authentication.requiredScopes +authentication.requiredScopes[] authentication.revokedKeyIds authentication.revokedKeyIds[] authentication.tokenTypes @@ -1518,6 +1552,9 @@ authorityProfiles.*.grants[].subjects[].valueClaims authorityProfiles.*.grants[].subjects[].valueClaims.* authorityProfiles.*.grants[].subjects[].valueOrigin authorityProfiles.*.kind +authorityProfiles.*.grantSourceIssuer +authorityProfiles.*.requesterClients +authorityProfiles.*.requesterClients[] authorityProfiles.*.requesterTags authorityProfiles.*.requesterTags[] holderBoundBatchMaxSize @@ -1660,6 +1697,7 @@ sourceConnections.*.authentication.headerName sourceConnections.*.authentication.kind sourceConnections.*.authentication.maximumCacheSeconds sourceConnections.*.authentication.passwordRef +sourceConnections.*.authentication.resource sourceConnections.*.authentication.scheme sourceConnections.*.authentication.scope sourceConnections.*.authentication.tokenEndpoint @@ -1684,6 +1722,7 @@ sources.*.authentication.headerName sources.*.authentication.kind sources.*.authentication.maximumCacheSeconds sources.*.authentication.passwordRef +sources.*.authentication.resource sources.*.authentication.scheme sources.*.authentication.scope sources.*.authentication.tokenEndpoint diff --git a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml index 7a4d4ab871..d70528cdbd 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/dhis2-tracker-evidence/bundle/evidence.yaml @@ -16,8 +16,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml index 581ee5a0e7..7593800be5 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/evidence.yaml @@ -10,14 +10,13 @@ authentication: kind: oidc-access-token issuer: https://identity.gov.example audiences: [registry-evidence] + allowedClients: [casework-agent] tokenTypes: [at+jwt] algorithms: [ES256] jwksUri: https://identity.gov.example/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: @@ -186,6 +185,8 @@ sources: factSchema: schemas/birth-parents-facts.schema.yaml authorityProfiles: civil-registration-caseworker-v1: + requesterClients: [casework-agent] + grantSourceIssuer: https://casework.gov.example kind: statutory requesterTags: [civil-registration-caseworker] grants: diff --git a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml index 1fe560dff0..fc4255e44d 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/opencrvs-family-evidence/bundle/fixtures/registered-parent-relationship-cases.yaml @@ -17,8 +17,6 @@ common: profile: civil-person-reference-v1 values: {person_reference: PERSON-SYNTHETIC-A} verified_token_claims: - evidence_grant_id: synthetic-parentage-grant-001 - evidence_authority: civil-registration-caseworker-v1 grant: candidate_parent: {person_reference: PERSON-SYNTHETIC-A} expectedRequestParts: diff --git a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml index 788c0b20f5..82607231eb 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml @@ -21,8 +21,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: diff --git a/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml index 65b243734c..ef03d9a3f1 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml @@ -20,8 +20,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: From f59686e07f64cd749ffb451d7d521ca5d7037baf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:33:52 +0700 Subject: [PATCH 007/120] docs(auth): checkpoint contextual authorization documentation Work-package checkpoint for contextual authorization. Integration acceptance remains in progress; unfinished fixture migrations are intentionally WIP. Citizen federation is preserved but deferred and is not required for phases 1-3. Signed-off-by: Jeremi Joslin --- .../content/docs/configure/breg-access.mdx | 46 ++++----- .../docs/configure/breg-change-control.mdx | 24 ++--- .../content/docs/configure/breg-journeys.mdx | 8 +- .../docs/configure/enable-sd-jwt-vc.mdx | 2 +- .../docs/configure/evidence-oid4vci.mdx | 67 ++++++++----- .../esignet-authentication-over-breg.mdx | 2 +- .../explanation/governed-registry-actions.mdx | 6 +- .../membership-read-boundaries.mdx | 14 +-- .../registry-modeling-patterns.mdx | 6 +- .../content/docs/reference/evidencectl.mdx | 24 +++-- .../control-who-can-request-evidence.mdx | 93 ++++++++++--------- .../docs/tutorials/evidence-from-breg.mdx | 29 +++--- .../tutorials/first-evidence-assertion.mdx | 18 ++-- .../request-a-holder-bound-credential.mdx | 2 +- .../request-evidence-from-an-application.mdx | 6 +- .../tutorials/return-a-governed-value.mdx | 4 +- 16 files changed, 189 insertions(+), 162 deletions(-) diff --git a/docs/site/src/content/docs/configure/breg-access.mdx b/docs/site/src/content/docs/configure/breg-access.mdx index 194c020c38..cb20d14829 100644 --- a/docs/site/src/content/docs/configure/breg-access.mdx +++ b/docs/site/src/content/docs/configure/breg-access.mdx @@ -12,8 +12,8 @@ standards_referenced: [] --- You are authoring a registry project that `bregctl check` compiles, and you want to decide who may read and write what. -This page covers access profiles and their grants, the three field lists that bound a response, a filter, and a write, the row boundaries that tie rows to a caller's claims, the requirements an entity imposes on every profile that touches it, and the offline preview that tells you whether a set of synthetic claims would be admitted. -At the end, every profile grants what its callers need and nothing more, and you can show a reviewer why. +This page covers access profiles and their permissions, the three field lists that bound a response, a filter, and a write, the row boundaries that tie rows to a caller's claims, the requirements an entity imposes on every profile that touches it, and the offline preview that tells you whether a set of synthetic claims would be admitted. +At the end, every profile permissions what its callers need and nothing more, and you can show a reviewer why. ## One profile per request @@ -57,7 +57,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:sites:plan] requiredPurposes: [site-planning] - grants: + permissions: - entity: asset-site operations: [get, list] readableFields: [site-code, label, zone] @@ -80,7 +80,7 @@ accessProfiles: readableFields: [person-code, legal-name] - id: public-directory anonymous: true - grants: + permissions: - entity: asset-site rowBoundaries: [] operations: [get, list] @@ -100,16 +100,16 @@ A profile with no required scope raises `access.profile.no_required_scope`, beca ## Grant operations, fields, and rows -Each entry under `grants` names one entity, or one action, and what the profile may do with it. +Each entry under `permissions` names one entity, or one action, and what the profile may do with it. -Three field lists bound every entity grant. +Three field lists bound every entity permission. `readableFields` decide what a response may carry, `writableFields` what a create or patch may set, and `filterableFields` which fields `$filter` may name; `sortableFields` and `allowCount` do the same for `$orderby` and `$count`. A write never reveals a field the profile cannot read. -Field ids in grants are the authored ids, `asset-code`, not the API names, `assetCode`, and a grant that names a field the entity does not have fails `check`. +Field ids in permissions are the authored ids, `asset-code`, not the API names, `assetCode`, and a permission that names a field the entity does not have fails `check`. A profile that grants `create` or `patch` without any writable field raises `access.profile.no_writable_fields`, because every write naming a field would then be refused. -Every entity grant declares `rowBoundaries` explicitly, as do action targets, review targets, apply targets, and request-presence grants. -Write `rowBoundaries: []` when the grant intentionally has no row restriction; omitting the member is an error. +Every entity permission declares `rowBoundaries` explicitly, as do action targets, review targets, apply targets, and request-presence permissions. +Write `rowBoundaries: []` when the permission intentionally has no row restriction; omitting the member is an error. An empty list grants all rows within the other permissions, so use it only where that breadth is intended. A row boundary compares a declared field with a verified token claim: `equals` against one claim value, or `in` against a claim carrying a list, which the token issuer must then issue as a JSON array. @@ -117,8 +117,8 @@ On reads, rows outside the boundary do not exist for the caller: a get returns 4 Writes check both the existing record, where one exists, and the proposed record against the same boundaries. Bind the boundary to whatever field carries your registry's tenancy, an owning office, a jurisdiction code, a programme. Decide deliberately which profiles that only patch may write that field, because a profile that can patch it moves records in and out of another caller's rows. -A grant that also creates has no such choice: the boundary compiles to an INSERT `WITH CHECK` pinning the field to the caller's claim, so the field has to stay in `writableFields` and each create has to send a value the claim allows. -`check` reports a writable boundary field as `access.profile.writable_row_boundary` either way, with the advice the grant's operations call for, and reports a create whose boundary field is missing from `writableFields` as `access.profile.row_boundary_not_writable`, because the row policy refuses every such create. +A permission that also creates has no such choice: the boundary compiles to an INSERT `WITH CHECK` pinning the field to the caller's claim, so the field has to stay in `writableFields` and each create has to send a value the claim allows. +`check` reports a writable boundary field as `access.profile.writable_row_boundary` either way, with the advice the permission's operations call for, and reports a create whose boundary field is missing from `writableFields` as `access.profile.row_boundary_not_writable`, because the row policy refuses every such create. A profile that can list an entity with no row boundary raises `access.profile.unrestricted_collection`, because caller-supplied filters are never authorization; accept it only for a registry-wide role. Use the principal claim for an ownership boundary when the field stores that same identity, for example @@ -128,23 +128,23 @@ All boundaries must hold, so combining owner and district restricts the caller t The same claim must have a consistent type and cardinality throughout the project. Principal reuse passes the exact identity string through the field's normal validation; it does not convert the identity or infer assignments. -The remaining grant members: +The remaining permission members: | Member | Effect | |---|---| -| `lookups` | The selector profiles this grant may use, and whether the values come from the `request` or from a `verified_claim` with a `claimMapping`. | -| `readPaths` | The read paths this grant may follow and the target fields it may read. | +| `lookups` | The selector profiles this permission may use, and whether the values come from the `request` or from a `verified_claim` with a `claimMapping`. | +| `readPaths` | The read paths this permission may follow and the target fields it may read. | | `spatialQueries.bbox` | Allows bounding-box queries and caps their longitude and latitude span in degrees. | | `revisionAccess` | With the `revisions` operation, lets the profile read a record's revision history. `check` reports `access.profile.revision_history`, because history can disclose values removed from the current record. | | `allowDataExport` | Enables bulk export through the profile. `check` reports `access.profile.data_export`, because disabling it later cannot recall downloaded data. | | `requestPresence` | Lets a reader of a target entity see that a change request of the named `requestType` is pending against a record. | | `reviewStages`, `applyTargets` | Which review stages a reviewer may decide, and which target entities an applier may write. | | `submitterTargets` | Requires the submitter to currently hold same-profile `get` authority over every native-reference target a change request names. | -| `action`, `targets`, `results` | An action grant instead of an entity grant. | +| `action`, `targets`, `results` | An action permission instead of an entity permission. | The last four rows belong to [Declare change requests and actions](../breg-change-control/). -The operations a grant may list: +The operations a permission may list: | Operation | Grants | |---|---| @@ -175,7 +175,7 @@ An entity can state what any profile touching it must satisfy, and the compiler A profile that grants the entity without the scope, with another purpose, or without the exact field, claim, and operator binding fails the check. Requirements never grant access; they only refuse a profile that would widen it. -Use requirements for limits that apply to every role, including actions and workflow target grants. +Use requirements for limits that apply to every role, including actions and workflow target permissions. An exact district binding also applies to a supervisor; it is unsuitable as a universal requirement if that supervisor should have registry-wide access. Requirements grant no exemption based on a profile's name. @@ -187,7 +187,7 @@ Requirements grant no exemption based on a profile's name. bregctl explain access ./my-registry ``` -The plain-text report lists entity profiles with each grant's row restrictions; `--format json` adds action grants, nested workflow permissions, and the row reach of every target. +The plain-text report lists entity profiles with each permission's row restrictions; `--format json` adds action permissions, nested workflow permissions, and the row reach of every target. For the `record-reader` profile of the project `init` wrote, the block reads: ```text @@ -213,7 +213,7 @@ For the `record-reader` profile of the project `init` wrote, the block reads: The other profiles and the findings are omitted. Read the complete report as a reviewer: inspect direct operations, action targets, and workflow targets separately. -A direct entity grant does not describe all authority held through actions or change requests. +A direct entity permission does not describe all authority held through actions or change requests. ## Preview synthetic claims @@ -276,15 +276,15 @@ Follow its README to compare profile admission with the limits that need a runni | Symptom | Cause and next move | |---|---| | A request is refused when `accessProfile` is omitted. | Several profiles expose that operation or path and none is the default. Have the application select the intended profile; it must still satisfy that profile's permissions. | -| `check` refuses a grant because the entity has no such field. | Field ids in grants are the authored kebab-case ids, not the camelCase API names. Use `asset-code`, not `assetCode`. | +| `check` refuses a permission because the entity has no such field. | Field ids in permissions are the authored kebab-case ids, not the camelCase API names. Use `asset-code`, not `assetCode`. | | The preview refuses with `required_scope_missing`, `purpose_missing_or_not_allowed`, or `principal_missing_or_mismatched`. | The scenario's claims do not satisfy the profile. Compare `scopes`, `purpose`, and `principalClaim` with the profile's `requiredScopes`, `requiredPurposes`, and `principalClaim`. | | The preview refuses with `row_claim_missing_or_wrong_cardinality`. | A row boundary found no claim, or an `in` boundary found a single value where it needs a list. Add the claim under `directClaims` with the cardinality the operator expects. | -| The preview refuses with `operation_not_granted` or `entity_or_profile_not_found`. | The scenario names an operation the grant does not list, or an entity or profile the project does not declare. Check the ids against `explain access`. | +| The preview refuses with `operation_not_granted` or `entity_or_profile_not_found`. | The scenario names an operation the permission does not list, or an entity or profile the project does not declare. Check the ids against `explain access`. | | `check` reports `access.profile.unrestricted_collection`. | A profile lists an entity with no row boundary. Add a claim-bound boundary, or accept a registry-wide role knowingly and record why. | ## Next -- [Declare change requests and actions](../breg-change-control/): the grants that submit, review, apply, and invoke. +- [Declare change requests and actions](../breg-change-control/): the permissions that submit, review, apply, and invoke. - [Test with journeys](../breg-journeys/): prove a row boundary with a refusal step, which no offline check can. - [Deploy a registry](../../operate/breg/): what a token must carry and where it comes from. -- [Base Registry Engine configuration reference](../../reference/breg-configuration/): every profile and grant key. +- [Base Registry Engine configuration reference](../../reference/breg-configuration/): every profile and permission key. diff --git a/docs/site/src/content/docs/configure/breg-change-control.mdx b/docs/site/src/content/docs/configure/breg-change-control.mdx index b550b6502b..d5e14873cb 100644 --- a/docs/site/src/content/docs/configure/breg-change-control.mdx +++ b/docs/site/src/content/docs/configure/breg-change-control.mdx @@ -89,7 +89,7 @@ entities: | Member | Effect | |---|---| -| `changeControl.requiredFor` | The operations on the target that must go through a request. With `[patch]`, a direct patch of a placement is refused even for a profile that holds `patch`: the operation is absent from ordinary grants, and only applying a request performs it. | +| `changeControl.requiredFor` | The operations on the target that must go through a request. With `[patch]`, a direct patch of a placement is refused even for a profile that holds `patch`: the operation is absent from ordinary permissions, and only applying a request performs it. | | `changeRequest.effects` | What applying the request writes. `target` names the record from a reference field, `operation` is `create` or `patch`, `set` maps target fields to request fields, and `clear` lists target fields to null. A request entity declares either `effects` or a `planner`, never both. | | `review.stages` | Stages run in order. Each needs `approvals` distinct approvers. `excludeSubmitter` keeps the submitter out of that stage; `excludePreviousReviewers` keeps actors who decided an earlier stage of the current proposal out of this one. A request that needs no review declares `review.mode: none` instead of stages; submitting it moves it straight to `approved`. | | `application.mode` | Who applies. The default, `manual`, waits for an applier. `automatic` applies inside the submit, or inside the final approval when stages exist; one profile must then hold that action together with `apply_request` and `applyTargets` for every target. `planner` leaves the choice to a script: `allowedDispositions` lists which of `apply` and `queue` it may return, and a queued request names one of `queueReasons`. | @@ -106,7 +106,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:submit] requiredPurposes: [asset-correction] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [create, get, patch, submit_request, revise_request, cancel_request] @@ -117,7 +117,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:review] requiredPurposes: [asset-correction-review] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, list, approve_request, reject_request, request_revision] @@ -132,7 +132,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:final-approve] requiredPurposes: [asset-correction-review] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, approve_request, reject_request, request_revision] @@ -147,7 +147,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:corrections:apply] requiredPurposes: [asset-correction-apply] - grants: + permissions: - entity: placement-correction-request rowBoundaries: [] operations: [get, apply_request] @@ -170,7 +170,7 @@ The manual applier may also have participated in review; applying is a separatel A reviewer's `reviewStages` names the stages the profile may decide and the target fields it may see while deciding. An applier's `applyTargets` names the entities the profile may write when it applies, with row boundaries of its own. Give the profiles that read the target entity a `requestPresence` entry so they can see that a correction is pending. -A grant's `requestVisibility: owner` restricts reads of the request entity to the requests the caller's own principal created, which suits a submitter. +A permission's `requestVisibility: owner` restricts reads of the request entity to the requests the caller's own principal created, which suits a submitter. The request record moves through `draft`, `submitted`, `needs_changes`, `rejected`, `approved`, `applied`, and `canceled`. Applying writes the target the way a direct mutation would, so the target gains a revision and the request moves to `applied`. @@ -259,7 +259,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [starter:holder] requiredPurposes: [starter-learning] - grants: + permissions: - entity: professional-license operations: [get, list] readableFields: [person-reference, licensed-activities] @@ -276,9 +276,9 @@ accessProfiles: submitterTargets: [professional-license] ``` -`submitterTargets` must name exactly the request's target entities: one entry for every entity its `effects` write, no more and no fewer. Each field the effects read a target from, `record` above, must be `required` on the request entity and readable through the grant, so admission can always read the target id it names, and writable through it wherever the profile holds `create` or `patch` on the request, so the submitter can supply the reference its own request needs. Application must stay `manual`: a `planner` or `automatic` request cannot declare it. No target may itself be a change-request entity, since a request's own read authority is the thing being checked. The profile itself must be non-anonymous and cannot hold `batch` on the request entity. And the same profile id must carry a grant on the target entity with `get` in its `operations` and no `membershipBoundaries`; a `rowBoundaries` restriction is fine, and the holder's own row boundary on `person-reference` above is what actually limits which licences a holder may target. `check` refuses a grant that cannot honour all of this, with `change_request.submitter_targets.invalid`. +`submitterTargets` must name exactly the request's target entities: one entry for every entity its `effects` write, no more and no fewer. Each field the effects read a target from, `record` above, must be `required` on the request entity and readable through the permission, so admission can always read the target id it names, and writable through it wherever the profile holds `create` or `patch` on the request, so the submitter can supply the reference its own request needs. Application must stay `manual`: a `planner` or `automatic` request cannot declare it. No target may itself be a change-request entity, since a request's own read authority is the thing being checked. The profile itself must be non-anonymous and cannot hold `batch` on the request entity. And the same profile id must carry a permission on the target entity with `get` in its `operations` and no `membershipBoundaries`; a `rowBoundaries` restriction is fine, and the holder's own row boundary on `person-reference` above is what actually limits which licences a holder may target. `check` refuses a permission that cannot honour all of this, with `change_request.submitter_targets.invalid`. -At runtime the engine reuses that same target grant's current `get` authority, never the request entity's own boundaries. Two checks carry it. The route itself refuses `create`, a draft `patch`, `submit_request`, and `revise_request` unless the caller's claims resolve to target authority under that grant: a claim that carries no matching value, or one a boundary cannot reduce to the single value it needs, leaves the route unauthorized and answers `404`, the same concealment an unrelated caller gets. Then `submit_request` and `revise_request` admit the named target rows inside the request transaction, under a share lock on each target table, and an exact idempotent replay admits them again before it replays: a target row the caller can no longer read through that grant answers `412` and `precondition.failed`. Reads are symmetric: once the request is retained, its target links appear only to a reader who currently holds that same `get` authority. +At runtime the engine reuses that same target permission's current `get` authority, never the request entity's own boundaries. Two checks carry it. The route itself refuses `create`, a draft `patch`, `submit_request`, and `revise_request` unless the caller's claims resolve to target authority under that permission: a claim that carries no matching value, or one a boundary cannot reduce to the single value it needs, leaves the route unauthorized and answers `404`, the same concealment an unrelated caller gets. Then `submit_request` and `revise_request` admit the named target rows inside the request transaction, under a share lock on each target table, and an exact idempotent replay admits them again before it replays: a target row the caller can no longer read through that permission answers `412` and `precondition.failed`. Reads are symmetric: once the request is retained, its target links appear only to a reader who currently holds that same `get` authority. {/* Evidence: products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/contract.rs, AccessGrantSource; crates/registry-breg/src/change_request.rs; crates/registry-breg/src/mutation/request.rs, admit_submitter_targets(); crates/registry-breg/src/api/mod.rs, authorize_direct_route_base(); crates/registry-breg/src/idempotency.rs; crates/registry-breg/src/postgres/request_read.rs, target_get_is_authorized(); crates/registry-breg/src/problem.rs. */} @@ -487,14 +487,14 @@ actions: `inputs` use the same types and members as fields, except that an input cannot declare `validTimeRole`. Each effect has an `id`, a `target` that is either a fixed `entity` or a record named by an input reference through `fromField`, `set` values that come `fromField` or `fromEffect`, so a later effect can reference a record an earlier effect created, and an optional `clear` list for a patch. -The grant names the action, the targets it may write, and the effect results the caller gets back: +The permission names the action, the targets it may write, and the effect results the caller gets back: ```yaml - id: asset-registrar principalClaim: registry_principal requiredScopes: [registry:asset:register] requiredPurposes: [asset-registration] - grants: + permissions: - action: register-asset-with-inspection operations: [invoke] targets: @@ -537,6 +537,6 @@ The report lists every compiled action with its inputs, effects, grants, contrac - [Review changes before updating a registry](../../tutorials/review-registry-changes/): submit, review, and apply a request against a running registry. - [Test with journeys](../breg-journeys/): write the submit, approve, apply, and refusal steps that prove the workflow. -- [Control access per profile](../breg-access/): the grant members every role on this page depends on. +- [Control access per profile](../breg-access/): the permission members every role on this page depends on. - [Base Registry Engine API reference](../../reference/breg-api/#change-requests): each request action, its route, and the states it accepts. - [Retain, erase, and audit](../../operate/breg-retention/): what `operator_erase` lets an operator remove later. diff --git a/docs/site/src/content/docs/configure/breg-journeys.mdx b/docs/site/src/content/docs/configure/breg-journeys.mdx index 4269ed18dd..6411211a44 100644 --- a/docs/site/src/content/docs/configure/breg-journeys.mdx +++ b/docs/site/src/content/docs/configure/breg-journeys.mdx @@ -73,7 +73,7 @@ bregctl explain queries ./my-registry `model` prints every entity, field, and profile as compiled. `routes` prints every HTTP route with its method, path, operation, and the profiles that may select it, including the default. `queries` prints, per entity and operation, the fields a response may carry, the filterable fields with example `$filter` expressions, and the query bounds a request stays within. -`access` prints the compiled grants per profile in a human-readable form and, with `--scenario`, previews admission; [control access per profile](../breg-access/) covers it. +`access` prints the compiled permissions per profile in a human-readable form and, with `--scenario`, previews admission; [control access per profile](../breg-access/) covers it. `change-requests` and `actions` are covered on [declare change requests and actions](../breg-change-control/), and `events` on [author a registry project](../breg/). Read `explain queries` before you write a client or a journey: a request that filters on a field the profile cannot filter is refused, not empty. @@ -110,7 +110,7 @@ These are all the findings the compiler can report, each attached to the documen | `access.profile.no_writable_fields` | The profile grants `create` or `patch` and names no writable field. | List the writable fields, or remove the write operations. | | `access.profile.related_disclosure` | A read path discloses related records under the root profile rather than the target entity's own profiles. | Review its fields and the target entity's access requirements. | | `access.profile.revision_history` | History can disclose values removed from the current record. | Review historical disclosure separately. | -| `access.profile.row_boundary_not_writable` | The profile grants `create` and a row boundary whose field it cannot write, so the row policy refuses every create. | Add the boundary field to the writable fields, or remove `create` from the grant. | +| `access.profile.row_boundary_not_writable` | The profile grants `create` and a row boundary whose field it cannot write, so the row policy refuses every create. | Add the boundary field to the writable fields, or remove `create` from the permission. | | `access.profile.snapshot_history` | Snapshot reads reproduce retained historical rows under current authorization. | Review stored-field projection, filters, and row boundaries separately. | | `access.profile.unrestricted_collection` | The profile can list every row; caller filters are not authorization. | Add a claim-bound row boundary, or accept a registry-wide role knowingly. | | `access.profile.unrestricted_rows` | The profile's granted operations reach every row and no claim-bound boundary narrows them. | Add a claim-bound row boundary, or accept a registry-wide role knowingly. | @@ -230,10 +230,10 @@ Running the journeys needs more than the project: an empty PostgreSQL database, | `check` passes but `check --deny-findings` fails. | A finding is present. Read it and either change the project or accept the finding knowingly. | | `generate` fails with `output.parent.invalid`. | The output directory's parent does not exist. Create it, or give a destination inside a directory that exists. | | `generate` fails with `output.destination.invalid`. | The output directory already exists, or the path contains `..`. Give a new path; remove the old directory first if you meant to regenerate. | -| A journey step fails with 404 where you expected data. | The profile's row boundary or purpose excluded the caller. Compare the step's `claims` with the grant, or preview them with `explain access --scenario` ([control access per profile](../breg-access/)). | +| A journey step fails with 404 where you expected data. | The profile's row boundary or purpose excluded the caller. Compare the step's `claims` with the permission, or preview them with `explain access --scenario` ([control access per profile](../breg-access/)). | | A journey filters or sorts on a field and is refused. | The profile's `filterableFields` or `sortableFields` do not list it. Check `explain queries` for the forms the profile may use. | | `bregctl dev` refuses the start with `test.step.failed` at `journeys[0].steps[2]`. | The rehearsal ran your journeys and that step failed. The refusal carries the step's own message; correct the step and start again. The full report stays in the project's private `.breg/dev/logs`. | -| A step is refused because a logical reference was refused. | The refusal names the class: a field the entity does not declare, a field the grant does not make writable, a request body with no field, a step identifier that is not stable, a step naming both an entity and an action or neither, or a capture no earlier step declares. It never repeats the value you wrote. | +| A step is refused because a logical reference was refused. | The refusal names the class: a field the entity does not declare, a field the permission does not make writable, a request body with no field, a step identifier that is not stable, a step naming both an entity and an action or neither, or a capture no earlier step declares. It never repeats the value you wrote. | ## Next diff --git a/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx b/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx index f0f007d040..279024809b 100644 --- a/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx +++ b/docs/site/src/content/docs/configure/enable-sd-jwt-vc.mdx @@ -38,7 +38,7 @@ authorityProfiles: approved-caller: kind: explicit-request requesterTags: [approved-caller] - grants: + permissions: - requirement: urn:example:requirement:age-bracket:v1 purpose: service-path-selection audienceFrom: authenticated-requester diff --git a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx index 689f53b11a..3d29409917 100644 --- a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx +++ b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx @@ -32,7 +32,7 @@ Evidence authorization decision, source acquisition, derivation, and signature s Evidence Gateway runtime contract. The dependency between the two services runs one way: `evidence-oid4vci` is a client of Evidence -Gateway and, for its adopter-facing endpoint, a resource server for Registry Mint tokens. No +Gateway and, for its adopter-facing endpoint, a resource server for issuer-issued tokens. No Evidence Gateway crate depends on `evidence-oid4vci` at runtime. Use `evidence-oid4vci` only for credentials issued under Evidence Gateway's `holder-bound` subject @@ -130,17 +130,20 @@ metricsListener: port: 9090 evidence: baseUrl: https://evidence.example.org -mint: - tokenEndpoint: https://mint.example.org/token +tokenClient: + tokenEndpoint: https://id.example.org/oauth2/token clientId: evidence-oid4vci privateKeyFile: keys/delivery-client.jwk.json - clientAssertionAudience: https://mint.example.org/token + clientAssertionAudience: https://id.example.org + resource: https://evidence.example.org + scopes: [evidence:invoke] offers: - issuer: https://mint.example.org - jwksUri: https://mint.example.org/.well-known/jwks.json - audiences: [evidence-oid4vci] - algorithms: [EdDSA] + issuer: https://id.example.org + jwksUri: https://id.example.org/oauth2/jwks + audiences: [https://wallet.example.org] + algorithms: [RS256] authorizedClients: [adopter-front-end] + requiredScopes: [oid4vci:offer] maximumTokenLifetimeSeconds: 900 store: maximumOffers: 4096 @@ -174,13 +177,19 @@ operator-private loopback or private address. It cannot share the delivery liste The delivery listener does not serve metrics, and the metrics listener does not serve protocol or adopter routes. -`mint.privateKeyFile` is this service's own private JWK, read owner-only when the outbound client -to Registry Mint is built, and never logged or rendered. `mint.clientAssertionAudience` defaults to -`mint.tokenEndpoint` when absent, which is the usual registration. +`tokenClient.privateKeyFile` is this service's own private JWK, read owner-only when its +outbound OAuth client is built, and never logged or rendered. +`tokenClient.clientAssertionAudience` defaults to `tokenClient.tokenEndpoint`. +Set `tokenClient.resource` to the exact Evidence Gateway resource identifier registered with the +issuer, and `tokenClient.scopes` to the scopes that client needs for Evidence Gateway. +The resource is an absolute URI without userinfo or a fragment; a stated scope list must contain +1 to 32 distinct RFC 6749 scope tokens. +Both settings are fixed for the lifetime of the outbound client and its token cache. +{/* Evidence: crates/registry-evidence-oid4vci/src/{config,issuer}.rs TokenClientConfig EvidenceIssuer::new; crates/registry-platform-httputil/src/client/private_key_jwt.rs PrivateKeyJwtConfig::with_resource PrivateKeyJwtConfig::with_scopes */} `offers` is the authorization boundary of the adopter-facing `POST /offers` endpoint, and it is a -required, separate document from `mint`: the identity this service authenticates to Mint with has -nothing to do with the identities it accepts offer tokens from. `offers.issuer` and +required, separate document from `tokenClient`: the identity this service authenticates to its +token issuer with has nothing to do with the identities it accepts offer tokens from. `offers.issuer` and `offers.jwksUri` name the authorization server that issues offer tokens, compared exactly against a token's `iss` and read for its keys; both must be `https` in `strict` mode. `offers.audiences` names the resource identifiers this service answers to, so a token minted for another resource @@ -189,9 +198,12 @@ server cannot be replayed here; at least one is required. `offers.algorithms` de defaults to empty, which accepts any client the issuer vouched for; naming one or more client identifiers narrows that further. `offers.maximumTokenLifetimeSeconds` defaults to `900` and is bounded to `60..=3600` seconds. +Set `offers.requiredScopes` when the issuer grants a dedicated offer capability. Every named +scope must occur in the verified offer token before the adapter creates an offer. +{/* Evidence: crates/registry-evidence-oid4vci/src/{config,authorizer}.rs required_scopes OfferAuthorizer */} `validationMode` defaults to `strict`, which requires every published and called origin -(`credentialIssuer`, `evidence.baseUrl`, `mint.tokenEndpoint`, the client assertion audience, +(`credentialIssuer`, `evidence.baseUrl`, `tokenClient.tokenEndpoint`, the client assertion audience, `offers.issuer`, and `offers.jwksUri`) to be an absolute `https` URL with a host and no embedded credentials, query, or fragment. Set it to `supervised-local-development` only for a disposable local developer environment; that mode admits one supervised process group on loopback, requiring @@ -212,7 +224,10 @@ evidence-oid4vci check --config /etc/evidence-oid4vci/oid4vci.yaml ``` `check` does everything `serve` does except bind a socket: it loads and validates the -configuration document and the Mint client key, then exits. `check`, `inspect`, and `serve` accept +configuration document and the token client key, then exits. +An old `mint:` block fails with an instruction to use `tokenClient:`. +When retaining Registry Mint as the token issuer, leave `resource` or `scopes` out if that +installation does not accept those OAuth request parameters. `check`, `inspect`, and `serve` accept `EVIDENCE_OID4VCI_CONFIG` in place of `--config`. Inspect the metadata derived from the validated configuration and authenticated Evidence @@ -265,7 +280,8 @@ evidence-oid4vci serve --config /etc/evidence-oid4vci/oid4vci.yaml ``` Every field in the configuration document is startup-only; changing the credential issuer, the -listener, the Evidence Gateway or Mint endpoints, or the store bounds means restarting the process. +listener, the Evidence Gateway or token issuer endpoints, or the store bounds means restarting +the process. Use the two process-local probes after the listener starts: @@ -281,7 +297,8 @@ curl --fail --silent https://wallet.example.org/ready `/health` reports that the process answers. `/ready` reports that the process holds its loaded configuration, key material, and state store. Readiness does not contact or report the condition -of Evidence Gateway or Registry Mint. Use the deployment's private upstream monitoring to observe +of Evidence Gateway or the configured token issuer. Use the deployment's private upstream +monitoring to observe those services. ## Monitor the private metrics listener @@ -326,11 +343,11 @@ failures and non-probe requests remain visible at `INFO`. | --- | --- | --- | | `GET /.well-known/openid-credential-issuer` | none | Publishes the issuer and credential endpoints, authorization server, supported credential configurations, and a batch ceiling only when the deployment accepts at least two proofs. The token endpoint is not published here. | | `GET /.well-known/oauth-authorization-server` | none | Publishes the token endpoint, the pre-authorized-code grant, `none` for token endpoint authentication, and anonymous pre-authorized access. | -| `POST /offers` | Registry Mint bearer token | Adopter-facing, not wallet-facing. Takes `credentialConfigurationId`, `subjects`, and a boolean `transactionCode`. Returns the offer object, its `openid-credential-offer://` URI, and, when one was asked for, the generated transaction code, once. | +| `POST /offers` | Issuer bearer token | Adopter-facing, not wallet-facing. Takes `credentialConfigurationId`, `subjects`, and a boolean `transactionCode`. Returns the offer object, its `openid-credential-offer://` URI, and, when one was asked for, the generated transaction code, once. | | `POST /token` | pre-authorized code, plus the transaction code when the offer set one | `grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code`. Returns `access_token`, `token_type`, `expires_in`. No `c_nonce`. | | `POST /nonce` | none | Empty body or empty JSON object in, `{"c_nonce": ...}` out, `Cache-Control: no-store`. The nonce is a keyed freshness challenge and is not bound to an access token. | | `POST /credential` | bearer access token | Takes `credential_configuration_id` and `proofs.jwt[]`. Returns `credentials: [{credential: ...}]`. | -| `GET /health`, `GET /ready` | none | Operational probes. Readiness deliberately reports nothing about the Evidence Gateway or Registry Mint deployments behind it, so a probe cannot be used to survey them. | +| `GET /health`, `GET /ready` | none | Operational probes. Readiness deliberately reports nothing about the Evidence Gateway or token issuer deployments behind it, so a probe cannot be used to survey them. | The transaction code is generated by this service and returned to the adopter once, on the `POST /offers` response, over the channel that request was authorized on. There is no way to supply one, @@ -338,10 +355,10 @@ which is what keeps a caller from choosing a guessable code. `POST /offers` is the real authorization boundary of this service. It accepts selector values and will cause a credential to be issued for whoever they identify, so protect it at least as strongly -as you would protect a direct call to Evidence Gateway. It is a resource server for Registry -Mint-issued tokens, verified the same way Evidence Gateway's own authenticator verifies them, and +as you would protect a direct call to Evidence Gateway. It is a resource server for issuer-issued +tokens, verified the same way Evidence Gateway's own authenticator verifies them, and that verification never shares a code path with this service's own outbound calls to Evidence -Gateway or Registry Mint. +Gateway or the configured token issuer. Only a `holder-bound` requirement can be named as a `credential_configuration_id`. A request naming an audience-scoped requirement is refused, so this service cannot be used to launder an @@ -398,7 +415,7 @@ transaction code reduce the usefulness of copied offer material. | The credential endpoint reports `invalid_proof` | The proof shape, algorithm, key source, audience, freshness, or distinct-key rule is outside the frozen profile. | Compare the request with published metadata, then obtain a new offer, access token, and nonce before retrying. | | The credential endpoint reports `credential_request_denied` | Evidence Gateway refused, lacked, or could not complete this already claimed exchange. | Diagnose Evidence Gateway through private monitoring, then create a new authorized offer after the upstream is usable. Do not retry the spent token. | | A new offer is refused under load | The bounded in-memory store is at capacity. | Stop creating offers, allow expired entries to be cleaned up, and size `maximumOffers` for the reviewed live window before the next restart. | -| Credential delivery reports an upstream failure | Registry Mint or Evidence Gateway did not complete the adapter's bounded outbound call. | Diagnose the upstream through its private monitoring. Start a new authorized exchange after the upstream recovers. | +| Credential delivery reports an upstream failure | The token issuer or Evidence Gateway did not complete the adapter's bounded outbound call. | Diagnose the upstream through its private monitoring. Start a new authorized exchange after the upstream recovers. | | The private metrics address refuses a connection | `metricsListener` is absent, invalid, occupied, or the process has not restarted on the edited configuration. | Run `check`, keep the binding private and distinct from the delivery listener, then restart the single replica. | Protocol errors deliberately do not reveal whether a secret value was unknown, expired, consumed, @@ -407,7 +424,7 @@ proofs, holder keys, credentials, selectors, subject identifiers, raw issuers, o ## Next -- [Configure Registry Mint](../mint/) to issue the access tokens `evidence-oid4vci` uses to - authenticate to Evidence Gateway and to protect its own `POST /offers` endpoint. +- [Configure Registry Mint](../mint/) if you retain Mint to issue the access tokens used for + Evidence Gateway and the adapter's `POST /offers` endpoint. - [Configure Evidence Gateway](../evidence/) for the deployment this service requests credentials from. diff --git a/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx b/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx index f667df2ced..54058b5f75 100644 --- a/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx +++ b/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx @@ -288,7 +288,7 @@ separate registrystack/solmara-lab repository (docs/esignet.md) and cannot be an - [Configure BReg access profiles](../../configure/breg-access/) to define the lookup-only grant. - [Run Registry Mint](../../configure/mint/) and register the provider as a `private_key_jwt` client. -- [Request an access token](../../configure/request-an-access-token/) to test the grant before +- [Request an access token](../../configure/request-an-access-token/) to test the permission before wiring eSignet. - [BReg HTTP API reference](../../reference/breg-api/) for the lookup route and problem documents. - [Data minimization and purpose limitation](../data-minimization-and-purpose-limitation/) for the diff --git a/docs/site/src/content/docs/explanation/governed-registry-actions.mdx b/docs/site/src/content/docs/explanation/governed-registry-actions.mdx index 9915ac441e..ca74b5c134 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-actions.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-actions.mdx @@ -12,7 +12,7 @@ standards_referenced: [] --- An immediate action is a named registry operation a caller invokes instead of writing records -directly. Base Registry Engine (BReg) admits the action's declared inputs, checks the grant that +directly. Base Registry Engine (BReg) admits the action's declared inputs, checks the permission that permits it, and applies a bounded set of writes in one transaction. A fixed-effect action maps admitted inputs onto declared effects. A handler action computes those effects in a Rhai script the project ships. Neither shape adds domain code to the server: an action stays configuration a @@ -92,7 +92,7 @@ Runtime configuration binds the same provider ID separately from the signed logi its own `evidenceProviders` map with `baseUrl`, `trustBindingId`, `tokenRef`, `trustedJwksRef`, `revokedKeyIds`, and an optional `caBundleRef`. The endpoint, token, and trusted JWKS are operator bindings that no script can replace. Computed identifiers stay request values: they cannot become -authenticated claims or grants, and the action grant covers all declared processing, so a caller +authenticated claims or grants, and the action permission covers all declared processing, so a caller input selecting optional disclosure adds no authority. The limits are fixed, and offline action explanation reports them: @@ -137,7 +137,7 @@ operator command that removes it once a window has closed. ## Next - [Declare change requests and actions](../../configure/breg-change-control/) to author the action, - its inputs, and the grant that invokes it. + its inputs, and the permission that invokes it. - [Base Registry Engine configuration reference](../../reference/breg-configuration/) for every authoring field a fragment names. - [Retain, erase, and audit](../../operate/breg-retention/) for the retention scopes an operator diff --git a/docs/site/src/content/docs/explanation/membership-read-boundaries.mdx b/docs/site/src/content/docs/explanation/membership-read-boundaries.mdx index e707e5c50e..0bf03f71f8 100644 --- a/docs/site/src/content/docs/explanation/membership-read-boundaries.mdx +++ b/docs/site/src/content/docs/explanation/membership-read-boundaries.mdx @@ -24,14 +24,14 @@ right to read it follows from an affiliation that changes over time. When the re authorized principal directly, an ordinary `rowBoundaries` claim comparison is the simpler mechanism and stays available. -## What the grant declares +## What the permission declares A facility stores `organization`, a reference to an `organization` entity. A separate `membership` entity stores an `organization` reference, a string `principal`, and a Boolean `active`. The boundary joins them inside the facility's read grant: ```yaml -grants: +permissions: - entity: facility rowBoundaries: [] operations: [get, list, lookup, snapshot, revisions] @@ -54,7 +54,7 @@ declared fields are authorization inputs, not an additional readable-field grant Every listed membership boundary and every ordinary `rowBoundaries` predicate must hold. Declare `rowBoundaries` explicitly even when membership is the only row restriction; `rowBoundaries: []` -adds no direct claim predicate and leaves the membership checks in force. A grant carries at most +adds no direct claim predicate and leaves the membership checks in force. A permission carries at most eight boundaries, reported as `access.membership.limit`. Configure the identity claim from a trusted issuer, and never accept a caller-submitted principal as authority. @@ -66,12 +66,12 @@ The refusals keep a membership boundary from being read as broader authority tha | --- | --- | | An anonymous profile, or one without a verified `principalClaim` | `access.membership.authentication` | | Writes, actions, reviewed changes, or request lifecycle grants | `access.membership.read_only` | -| A spatial bbox grant, which uses a separate database authority role | `access.membership.spatial_unsupported` | +| A spatial bbox permission, which uses a separate database authority role | `access.membership.spatial_unsupported` | | A membership entity with a change-request lifecycle | `access.membership.request_unsupported` | | More than eight boundaries on one grant | `access.membership.limit` | A membership-bounded grant supports get, lookup, list, snapshot, and revisions, plus counts when it -sets `allowCount`. It can protect the root of a relationship read path, and a target grant that +sets `allowCount`. It can protect the root of a relationship read path, and a target permission that would reach the same entity through a separate root is rejected rather than silently bypassing the rule. A membership source stays a leaf entity: no boundaries of its own, no change-request lifecycle, and no incoming relationship read paths, which is what keeps row-security policies from @@ -110,9 +110,9 @@ row-level-security bypass. ## Next - [Control access per profile](../../configure/breg-access/) to author the profile, its scopes, and - the grant the boundary sits in. + the permission the boundary sits in. - [Base Registry Engine configuration reference](../../reference/breg-configuration/) for every - grant key a fragment names. + permission key a fragment names. - [Modeling patterns for registries](../registry-modeling-patterns/) for the record structures a membership model joins. - [Current membership access](https://github.com/registrystack/registry-stack/blob/main/products/breg/membership-access.md) diff --git a/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx b/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx index 34e02a3fe9..aee71e5d39 100644 --- a/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx +++ b/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx @@ -28,7 +28,7 @@ JSON with synthetic records, not a captured server response; it doubles as a sam YAML field IDs such as `valid-from` become JSON names such as `validFrom`. Requests omit the deployment's host and bearer token. The [example reader profile](#access-for-the-example-queries) supplies the required read permissions; -selecting its name does not authenticate a caller. Creation and correction need separate write grants. +selecting its name does not authenticate a caller. Creation and correction need separate write permissions. {/* Evidence: crates/registry-breg/src/artifacts.rs, single_response_schema() and list_response_schema(); crates/registry-breg/src/api/mod.rs, read_dispatch(); @@ -686,7 +686,7 @@ permissions, and meaning independently of changes that only add optional metadat ## Access for the example queries -This profile grants the five example queries and no writes. It can list every row in these entities; +This profile permissions the five example queries and no writes. It can list every row in these entities; the example filters do not restrict authorization. Add it only if one reader should see all these rows and fields. Real deployments may need separate profiles and row restrictions. It requires a verified `registry_principal` claim, the scope `registry:examples:read`, and the purpose @@ -699,7 +699,7 @@ accessProfiles: principalClaim: registry_principal requiredScopes: [registry:examples:read] requiredPurposes: [registry-operations] - grants: + permissions: - entity: business-identifier rowBoundaries: [] operations: [list] diff --git a/docs/site/src/content/docs/reference/evidencectl.mdx b/docs/site/src/content/docs/reference/evidencectl.mdx index 7a86d63fe8..2d8c797d4b 100644 --- a/docs/site/src/content/docs/reference/evidencectl.mdx +++ b/docs/site/src/content/docs/reference/evidencectl.mdx @@ -110,13 +110,18 @@ evidencectl init --transport sqlite-extract --profile local OpenAPI mode creates an authoring workspace whose source, question, and fixture directories remain empty. SQLite mode creates a synthetic source, question, query, adapters, schemas, derivation, and fixture. Both create disposable, unbound Evidence Gateway key material in the ignored owner-only -`secrets/` directory. Neither creates a production target, Mint configuration, or deployable bundle. +`secrets/` directory. Neither creates a production target, issuer configuration, or deployable bundle. -`evidencectl dev start []` creates session-scoped P-256 pairs for Registry Mint and an SD-JWT VC -holder. It creates an implicit caller pair only when the project has no explicit access +`evidencectl dev start []` starts Evidence Gateway and the pinned local issuer on loopback. +It creates a session-scoped P-256 pair for an SD-JWT VC holder and an implicit caller pair only when the project has no explicit access policy; explicit local clients use their keys under `.evidence/clients/`. Nothing under `.evidence/dev` or `.evidence/clients` enters a production target. +`evidencectl dev token []` obtains a fresh service token and writes an owner-only +`Authorization` header file under `.evidence/dev/generated/keys/`. It reports the path and never +prints the token. Task grants come from the configured Casework authority and are not static local +client attributes. + `evidencectl access` manages caller access for one local project: | Command | State | Result | @@ -125,16 +130,15 @@ policy; explicit local clients use their keys under `.evidence/clients/`. Nothin | `evidencectl access policy list` | Reads `access/policies/` | Lists the governed local policies and their questions | | `evidencectl access client add --policy --generate-local-key` | Creates `access/clients/.yaml` and `.evidence/clients//private.jwk` | Registers a local client for repeated `--policy` values with non-overlapping question sets | | `evidencectl access client list` | Reads `access/clients/` | Lists local client status and policy membership | -| `evidencectl access client revoke ` | Updates `access/clients/.yaml` | Revokes the local client for new token requests and removes its live generated registration; retains `.evidence/clients//private.jwk` | +| `evidencectl access client revoke ` | Updates `access/clients/.yaml` | Revokes the local client for the next issuer generation and retains `.evidence/clients//private.jwk` | Public, reviewable local access configuration lives under `access/`. Owner-only private client keys -live only under `.evidence/clients/`. Generated Registry Mint registrations under -`.evidence/dev/generated/clients/` are disposable and contain public keys only. +live only under `.evidence/clients/`. Generated issuer resources under `.evidence/dev/generated/issuer/` +are disposable. -Client add and revoke request a Registry Mint reload only when `evidencectl dev start` has a ready local -session. They do not restart or reload Evidence Gateway. Registry Mint does not acknowledge completion of -the reload, so the next token exchange provides the functional check. A policy change requires a -new Evidence Gateway generation. +Stop the local development session before adding or revoking a client. The next start validates the +complete client and policy registry, then provisions it as one issuer generation. A policy change also +requires a new Evidence Gateway generation. The local access commands do not define production authority or production clients. Production authority profiles remain in the deployment target's governed `governance.yaml`. Production client diff --git a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx index 4b63263a22..7019cc2e0c 100644 --- a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx +++ b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx @@ -21,7 +21,7 @@ is running, and prove that a valid access token does not let its client ask ever question. reason=not_authorized +``` + +The privacy-safe refusal event contains a requester pseudonym and the closed `not_authorized` +reason. It does not contain the rejected requirement, purpose, selector, authority, response format, +client identifier, or access token. + +Revoke `age-checker` while the session is stopped, then start the next generation: ```sh evidencectl access client revoke age-checker +evidencectl dev start . ``` ```text Revoked client age-checker. -Registry Mint reload requested. +Evidence ready at http://127.0.0.1:8080 +Issuer ready at http://127.0.0.1:8081 ``` -The command updates `access/clients/age-checker.yaml` and requests another Registry Mint reload -without restarting Evidence Gateway. Revocation cannot erase a stateless access token already issued to -the application. That token remains valid for up to 300 seconds. +The command updates `access/clients/age-checker.yaml`. The next issuer generation excludes the +revoked client. Try to prepare a fresh request as the revoked client: @@ -363,29 +381,24 @@ evidencectl request prepare adult-status \ evidencectl: unknown or revoked active client age-checker ``` -`evidencectl` rejects an unknown or revoked named client locally, before contacting Registry Mint +`evidencectl` rejects an unknown or revoked named client locally, before contacting the issuer or publishing request artifacts. `service-router` remains registered and keeps its `service-routing` membership. -## Inspect the final audit operation +## Stop the final generation -Stop the local services before reading their completed audit chain: +Stop the local services: ```sh evidencectl dev stop -evidencectl audit show --last-operation ``` ```text Local Evidence stopped -ACCESS REFUSED requester= reason=not_authorized ``` -The privacy-safe refusal event contains a requester pseudonym and the closed `not_authorized` -reason. The event does not contain the rejected requirement, purpose, selector, authority, or -response format. It also does not contain a client identifier or access token. The revoked-client -preparation remains local and creates no Evidence Gateway audit event because no HTTP request was -sent. +The revoked-client preparation remains local and creates no Evidence Gateway audit event because no +HTTP request was sent. ## Clean up @@ -402,15 +415,12 @@ Return to the registry terminal and press `Ctrl+C`. - Authentication identifies the calling application but grants no question by itself. - Many applications can share a governed access policy without client-specific Evidence Gateway config. -- A client added while the services run can obtain a token without restarting Evidence Gateway or - Registry Mint. +- A client registry change takes effect only through one newly validated local generation. - Each application receives only the questions included in its assigned policies. - Evidence Gateway refuses unauthorized questions before reading the source. - Evidence Gateway retains a privacy-safe audit event for an authenticated authorization refusal. -- Revoking a local client requests a Registry Mint reload and prevents new local request +- Revoking a local client excludes it from the next issuer generation and prevents local request preparation. -- Client membership changes affect new tokens while existing tokens remain valid for up to 300 - seconds. - Adding a question or changing an access policy requires a new Evidence Gateway generation. - Source authentication and consumer trust remain separate from caller access. @@ -418,6 +428,5 @@ Return to the registry terminal and press `Ctrl+C`. - [Evidence authoring form reference](../../products/registry-evidence/authoring-form/) - [See Evidence Gateway refuse unsafe requests](../refuse-unsafe-evidence-requests/) -- [Configure Registry Mint](../../configure/mint/) - [Request an access token from application code](../../configure/request-an-access-token/) - [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) diff --git a/docs/site/src/content/docs/tutorials/evidence-from-breg.mdx b/docs/site/src/content/docs/tutorials/evidence-from-breg.mdx index c335be1af1..360f1e04b5 100644 --- a/docs/site/src/content/docs/tutorials/evidence-from-breg.mdx +++ b/docs/site/src/content/docs/tutorials/evidence-from-breg.mdx @@ -24,7 +24,7 @@ a synthetic record, then add a narrow Evidence source without resetting the regi outcome="A verified Evidence answer from a record retained across BReg stop and restart." time="About 30 minutes, plus the first PostgreSQL image download" level="Local development with synthetic records" - prerequisites={['Matching candidate breg, bregctl, evidence, evidencectl and mint binaries on PATH', 'A running Docker engine', 'A shell with curl, tar, Python 3 and awk', 'An editor']} + prerequisites={['Matching candidate breg, bregctl, evidence and evidencectl binaries on PATH', 'A running Docker engine', 'A shell with curl, tar, Python 3 and awk', 'An editor']} /> Use the matching candidate toolset that accompanies this page. Guided source setup @@ -59,16 +59,16 @@ bregctl init ./registry --from publicschema --selection ./organization-selection bregctl dev ./registry ``` -BReg starts PostgreSQL, Mint, and the registry. If defaults `8090`, `8091`, or `55432` +BReg starts PostgreSQL, the pinned local issuer, and the registry. If defaults `8090`, `8091`, or `55432` are occupied, choose unused ports on the first start instead: ```sh -bregctl dev ./registry --breg-port 8190 --mint-port 8191 \ +bregctl dev ./registry --breg-port 8190 --issuer-port 8191 \ --database-port 55433 ``` Use these ports only if they are available, or substitute another unused set. -Use your chosen BReg and Mint ports in the record requests below. Evidence setup +Use your chosen BReg and issuer ports in the record requests below. Evidence setup will obtain those retained endpoints directly. If startup fails, correct the named prerequisite and retry; the error gives a private diagnostics path. @@ -88,13 +88,9 @@ support messages. Obtain an operator token: ```sh -umask 077 -credentials=registry/.breg/dev/credentials/operator -mint token --url http://127.0.0.1:8091/token \ - --client-id "$(cat "$credentials/client-id")" --key "$credentials/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > authorization.header +bregctl dev token operator ./registry curl --silent --show-error --fail-with-body \ - --header @authorization.header --header 'Content-Type: application/json' \ + --header @registry/.breg/dev/secrets/operator.header --header 'Content-Type: application/json' \ --header 'Idempotency-Key: composition-create-1' \ --data '{"data":{"code":"SYNTHETIC-ORG-001","name":"Earlier name"}}' \ --output created.json \ @@ -105,11 +101,12 @@ Read the new record and capture its ETag before changing its name: ```sh record_id=$(python3 -c 'import json; print(json.load(open("created.json"))["data"]["recordIdentifier"])') -curl --silent --show-error --fail-with-body --header @authorization.header \ +curl --silent --show-error --fail-with-body --header @registry/.breg/dev/secrets/operator.header \ --dump-header record.headers --output before-edit.json \ "http://127.0.0.1:8090/v1/records/records/$record_id?accessProfile=operator" record_etag=$(awk 'tolower($1) == "etag:" {print $2}' record.headers | tr -d '\r') -curl --silent --show-error --fail-with-body --header @authorization.header \ +curl --silent --show-error --fail-with-body \ + --header @registry/.breg/dev/secrets/operator.header \ --header 'Content-Type: application/json-patch+json' \ --header 'Idempotency-Key: composition-edit-1' --header "If-Match: $record_etag" \ --request PATCH --data '[{"op":"replace","path":"/data/name","value":"Synthetic Works"}]' \ @@ -123,7 +120,7 @@ for comparison after setup. If a request returns `401`, repeat the token command If a concurrent edit changes the ETag, read the record again before deciding whether to retry the edit. -{/* Evidence: crates/registry-breg/tests/postgres_mutation.rs; crates/registry-bregctl/tests/dev_lifecycle.rs; crates/registry-mint/src/cli.rs. */} +{/* Evidence: crates/registry-breg/tests/postgres_mutation.rs; crates/registry-bregctl/tests/dev_lifecycle.rs. */} ## Connect Evidence after using the registry @@ -236,7 +233,7 @@ target's caller governance. Production targets are refused by this command. If Evidence's default ports `8080` and `8081` are occupied, start with `evidencectl dev --target ./targets/local --evidence-port 8180 ---mint-port 8181 start .` instead. Use that Evidence port in each curl command in this +--issuer-port 8181 start .` instead. Use that Evidence port in each curl command in this section. BReg's two ports are separate and stay as configured in the source connection. @@ -289,9 +286,9 @@ previous start: evidencectl dev --target ./targets/local start . ``` -The command reports the actual Evidence Gateway and Registry Mint endpoints it +The command reports the actual Evidence Gateway and issuer endpoints it reused. After `dev clean`, select any nondefault ports again with -`--evidence-port` and `--mint-port`. +`--evidence-port` and `--issuer-port`. BReg retains its database, generated identities, audit history and seed checkpoints. Existing record edits survive this restart. A lost checkpoint is diff --git a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx index 8c1f7319bb..5c674ba53d 100644 --- a/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx +++ b/docs/site/src/content/docs/tutorials/first-evidence-assertion.mdx @@ -44,7 +44,7 @@ sequenceDiagram Note over C,E: The assertion contains no person_id,
name, or date_of_birth ``` -Before the request, Registry Mint gives the tutorial caller short-lived local authorization. +Before the request, the pinned local issuer gives the tutorial caller short-lived local authorization. Evidence Gateway and the registry process the identifier and date of birth to answer the question, but the released assertion contains only the governed answer and an opaque subject binding. @@ -57,7 +57,7 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl evidencectl --version ``` -The installer provides `evidencectl` and the local Evidence Gateway and Registry Mint runtimes used later +The installer provides `evidencectl` and the local Evidence Gateway runtime used later in the tutorial. The platforms those binaries cover, and the C library floor they need, are in [platform support](../../explanation/known-limitations/#platform-support). @@ -358,7 +358,7 @@ evidencectl source mock serve --config mocks/source.yaml Source mock ready: mode=materialized origin=http://127.0.0.1:4010 served=3 skipped=0 ``` -Start Evidence Gateway and Registry Mint: +Start Evidence Gateway and the pinned local issuer: ```sh evidencectl dev start . @@ -366,10 +366,10 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` -This command starts Evidence Gateway and Mint only. The source mock remains the process you started in +This command starts Evidence Gateway and its local issuer only. The source mock remains the process you started in the first terminal. If either port is already taken, the start refuses before it starts a new generation and names the port and the flag that moves the session: `free 127.0.0.1:8080, or start this session with --evidence-port `. Use @@ -387,7 +387,7 @@ evidencectl request prepare adult-status \ ``` The Evidence client prepares the request and closes its pinned verification expectations locally, -before a response exists. `evidencectl` separately asks the local Mint for short-lived +before a response exists. `evidencectl` separately asks the local issuer for short-lived authorization. It sends no HTTP request to Evidence Gateway and does not contact the registry. The command creates exactly these owner-only artifacts: @@ -547,7 +547,7 @@ issuer metadata, tamper refusal, and structured-field projection. ## Stop the local services -Stop Evidence Gateway and Mint before reading the completed audit chain: +Stop Evidence Gateway and the local issuer before reading the completed audit chain: ```sh evidencectl dev stop @@ -579,10 +579,10 @@ record or access token. Choose two different unused ports when you start the local services: ```sh -evidencectl dev --evidence-port 8180 --mint-port 8181 start . +evidencectl dev --evidence-port 8180 --issuer-port 8181 start . ``` -Evidence Gateway uses the selected ports consistently in its runtime, Mint authorization, readiness +Evidence Gateway uses the selected ports consistently in its runtime, issuer authorization, readiness checks, and generated request artifacts. Send the assertion request to the selected Evidence Gateway port, such as `http://127.0.0.1:8180/v1/evidence`. diff --git a/docs/site/src/content/docs/tutorials/request-a-holder-bound-credential.mdx b/docs/site/src/content/docs/tutorials/request-a-holder-bound-credential.mdx index 983cd09d04..08b6a2f777 100644 --- a/docs/site/src/content/docs/tutorials/request-a-holder-bound-credential.mdx +++ b/docs/site/src/content/docs/tutorials/request-a-holder-bound-credential.mdx @@ -83,7 +83,7 @@ authorityProfiles: approved-caller: kind: explicit-request requesterTags: [approved-caller] - grants: + permissions: - requirement: urn:example:requirement:age-bracket:v1 purpose: service-path-selection audienceFrom: authenticated-requester diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index 14c5ed88a0..df23c4c976 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -43,7 +43,7 @@ program itself retained. %%{init: {"sequence": {"mirrorActors": false}}}%% sequenceDiagram participant A as Your application - participant M as Registry Mint + participant M as Local issuer participant E as Evidence A->>M: Signed client assertion @@ -166,7 +166,7 @@ Source mock ready: mode=materialized origin=http://127.0.0.1:4010 served=3 skipp ``` Back in the first terminal, compile the question and the access policy into a fresh generation, and -start Evidence and Registry Mint: +start Evidence and the pinned local issuer: ```sh evidencectl dev start . @@ -174,7 +174,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` `evidencectl dev` reports ready without reaching the source, so a stopped source mock surfaces only diff --git a/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx b/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx index 9e54742582..c4a55820e0 100644 --- a/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx +++ b/docs/site/src/content/docs/tutorials/return-a-governed-value.mdx @@ -131,7 +131,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` The command compiles both files in `questions/` into the same local Evidence Gateway service. The adult @@ -237,7 +237,7 @@ evidencectl dev stop evidencectl dev clean ``` -If ports `8080` or `8081` belong to something else, pass `--evidence-port` and `--mint-port` with +If ports `8080` or `8081` belong to something else, pass `--evidence-port` and `--issuer-port` with two unused ports, as [your first assertion](../first-evidence-assertion/) describes. ## Next From c681aa5b29bfc5dcba5a658e0e52eb7685d39b57 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:34:54 +0700 Subject: [PATCH 008/120] chore(clients): checkpoint issuer migration compatibility changes Signed-off-by: Jeremi Joslin --- .../tests/evidence_compatibility.rs | 2 + crates/registry-relay-client-node/client.d.ts | 16 +++++++ crates/registry-relay-client-node/src/lib.rs | 42 +++++++++++++++++++ .../python/registry_relay_client/__init__.pyi | 10 +++++ .../registry-relay-client-py/src/convert.rs | 35 ++++++++++++++++ 5 files changed, 105 insertions(+) diff --git a/crates/registry-mint/tests/evidence_compatibility.rs b/crates/registry-mint/tests/evidence_compatibility.rs index 4c525e8c35..5808c4d955 100644 --- a/crates/registry-mint/tests/evidence_compatibility.rs +++ b/crates/registry-mint/tests/evidence_compatibility.rs @@ -465,6 +465,8 @@ async fn evidence_fetches_keys_from_a_real_supervised_local_mint() { evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), grant_id_claim: GRANT_ID_CLAIM.to_owned(), grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), + allowed_clients: None, + required_scopes: None, actor_claim: None, }; Authenticator::from_config(&authentication, AssuranceProfile::Local) diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts index f2f9304e57..49eec30a23 100644 --- a/crates/registry-relay-client-node/client.d.ts +++ b/crates/registry-relay-client-node/client.d.ts @@ -14,7 +14,23 @@ export interface PrivateKeyJwtConfig { tokenEndpoint: string clientId: string clientKey: PrivateJwk + /** + * The audience of the client assertion (who checks the client's + * authentication). Defaults to the token endpoint URL. This is not the + * `resource` of the token request. + */ audience?: string | null + /** + * The RFC 8707 resource indicator the token is requested for: the resource + * server's registered identifier, not a URL to fetch. + */ + resource?: string | null + /** + * The scopes requested for the token, sent as one space-delimited `scope` + * parameter. A requested scope may narrow the client's registered + * permission set; it can never widen it. + */ + scopes?: ReadonlyArray | null assertionLifetimeSeconds?: SafeInteger | null refreshMarginSeconds?: SafeInteger | null requestTimeoutMilliseconds?: SafeInteger | null diff --git a/crates/registry-relay-client-node/src/lib.rs b/crates/registry-relay-client-node/src/lib.rs index 106a52baa6..906534d716 100644 --- a/crates/registry-relay-client-node/src/lib.rs +++ b/crates/registry-relay-client-node/src/lib.rs @@ -216,6 +216,30 @@ fn optional_string( } } +/// An optional member that, when present, must be an array of strings. The +/// values' own grammar is the provider's to check; this holds only the shape. +fn optional_string_array( + object: &Map, + field: &str, + kind: &'static str, + message: &'static str, +) -> Result>> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(values)) => values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| binding_error(kind, message)) + }) + .collect::>>() + .map(Some), + Some(_) => Err(binding_error(kind, message)), + } +} + fn bounded_safe_integer( value: &Value, minimum: i64, @@ -291,6 +315,8 @@ fn private_key_jwt(value: &Value) -> Result { "clientId", "clientKey", "audience", + "resource", + "scopes", "assertionLifetimeSeconds", "refreshMarginSeconds", "requestTimeoutMilliseconds", @@ -347,6 +373,22 @@ fn private_key_jwt(value: &Value) -> Result { )? { config = config.with_audience(value); } + if let Some(value) = optional_string( + object, + "resource", + "configuration", + "authorization.privateKeyJwt.resource must be a string", + )? { + config = config.with_resource(value); + } + if let Some(values) = optional_string_array( + object, + "scopes", + "configuration", + "authorization.privateKeyJwt.scopes must be an array of strings", + )? { + config = config.with_scopes(values); + } if let Some(value) = optional_i64( object, "assertionLifetimeSeconds", diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi index 667b0b1575..02ed141177 100644 --- a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi @@ -18,7 +18,17 @@ class _PrivateKeyJwtRequired(TypedDict): class PrivateKeyJwtConfig(_PrivateKeyJwtRequired, total=False): + # The audience of the client assertion (who checks the client's + # authentication); defaults to the token endpoint URL. This is not the + # `resource` of the token request. audience: Optional[str] + # The RFC 8707 resource indicator the token is requested for: the resource + # server's registered identifier, not a URL to fetch. + resource: Optional[str] + # The scopes requested for the token, sent as one space-delimited `scope` + # parameter. A requested scope may narrow the client's registered + # permission set; it can never widen it. + scopes: Optional[Sequence[str]] assertion_lifetime_seconds: Optional[int] refresh_margin_seconds: Optional[int] request_timeout_seconds: Optional[float] diff --git a/crates/registry-relay-client-py/src/convert.rs b/crates/registry-relay-client-py/src/convert.rs index f0dbbf292b..9f0e867f22 100644 --- a/crates/registry-relay-client-py/src/convert.rs +++ b/crates/registry-relay-client-py/src/convert.rs @@ -367,6 +367,33 @@ fn optional_string( } } +/// An optional member that, when present, must be a list of strings. The +/// values' own grammar is the provider's to check; this holds only the shape. +fn optional_string_list( + value: &Map, + field: &str, + what: &str, +) -> Result>, ConversionError> { + let Some(values) = value.get(field) else { + return Ok(None); + }; + if values.is_null() { + return Ok(None); + } + let values = values.as_array().ok_or_else(|| { + ConversionError::new(format!("{what}[\"{field}\"] must be a list of strings")) + })?; + values + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + ConversionError::new(format!("{what}[\"{field}\"] must contain only strings")) + }) + }) + .collect::, _>>() + .map(Some) +} + fn optional_i64( value: &Map, field: &str, @@ -415,6 +442,8 @@ fn private_key_jwt( "client_id", "client_key", "audience", + "resource", + "scopes", "assertion_lifetime_seconds", "refresh_margin_seconds", "request_timeout_seconds", @@ -438,6 +467,12 @@ fn private_key_jwt( if let Some(value) = optional_string(value, "audience", WHAT)? { config = config.with_audience(value); } + if let Some(value) = optional_string(value, "resource", WHAT)? { + config = config.with_resource(value); + } + if let Some(values) = optional_string_list(value, "scopes", WHAT)? { + config = config.with_scopes(values); + } if let Some(value) = optional_i64(value, "assertion_lifetime_seconds", WHAT)? { config = config.with_assertion_lifetime_seconds(value); } From a05bffa7bf2d5b2eead78cb364d7342e02f0cec2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:47:51 +0700 Subject: [PATCH 009/120] test(relay): replace Mint lookup fixture with stock issuer Exercise registered private-key JWT issuance and the protected lookup against pinned ThunderID 1.0.1. The Docker acceptance gate passes explicitly with --ignored; normal unit runs do not gain a Docker prerequisite. Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 +- crates/registry-relay-v2/Cargo.toml | 2 +- .../tests/acceptance_http.rs | 306 ++++++++---------- 3 files changed, 129 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36974dece4..a475a6a383 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4461,7 +4461,6 @@ dependencies = [ "oxjsonld", "p256", "registry-discovery-profile", - "registry-mint", "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", @@ -4475,6 +4474,7 @@ dependencies = [ "registry-platform-testing", "registry-relay-client", "registry-relay-http-contract", + "registry-thunderid-tooling", "reqwest", "rustix 1.1.4", "schemars", diff --git a/crates/registry-relay-v2/Cargo.toml b/crates/registry-relay-v2/Cargo.toml index bd7e4b1cd9..b8a113a9a5 100644 --- a/crates/registry-relay-v2/Cargo.toml +++ b/crates/registry-relay-v2/Cargo.toml @@ -70,7 +70,7 @@ jsonschema.workspace = true oxjsonld = "0.2.5" registry-platform-sqlite = { workspace = true, features = ["fixture"] } registry-platform-testing.workspace = true -registry-mint.workspace = true +registry-thunderid-tooling.workspace = true registry-platform-crypto.workspace = true registry-relay-client.workspace = true p256.workspace = true diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 1921e7f63a..ff02d05e73 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -18,10 +18,6 @@ use http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, ETAG, LINK, VARY} use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode}; use jsonschema::{Draft, JSONSchema}; use oxjsonld::JsonLdParser; -use registry_mint::{ - config::MintConfig, - server::{build_app as build_mint_app, MintService}, -}; use registry_platform_audit::{ AuditChainHasher, AuditEnvelope, AuditError, AuditSink, ChainState, JsonlFileSink, }; @@ -71,6 +67,13 @@ use registry_relay_v2::server::{ }; use registry_relay_v2::sqlite_runtime::{RuntimeSourceBinding, SqliteRuntime, SqliteRuntimeLimits}; use registry_relay_v2::startup::build_authenticator_for_supervised_local_development; +use registry_thunderid_tooling::{ + container::Session, + description::SessionIdentity, + local::{self, LocalClient}, + render, + version::ThunderIdPin, +}; use serde_json::{json, Map, Value}; use tempfile::TempDir; use tower::ServiceExt as _; @@ -167,184 +170,140 @@ impl ClientLoopback { } } -struct MintLoopback { +struct StockIssuerLoopback { issuer: String, token_provider: Arc, - shutdown: tokio::sync::oneshot::Sender<()>, - server: tokio::task::JoinHandle>, - _temp: TempDir, + label: String, + id: String, + port: u16, + image: String, + docker: PathBuf, + temp: TempDir, } -impl MintLoopback { +impl StockIssuerLoopback { async fn start_social_assistance(audience: &str) -> Self { - let listener = std::net::TcpListener::bind(("127.0.0.1", 0)) - .expect("Mint acceptance listener reserves"); - listener - .set_nonblocking(true) - .expect("Mint acceptance listener becomes nonblocking"); - let address = listener - .local_addr() - .expect("Mint acceptance address resolves"); - let issuer = format!("http://{address}"); - let token_endpoint = format!("{issuer}/token"); - - let temp = tempfile::tempdir().expect("Mint acceptance deployment creates"); - let root = temp.path(); - fs::create_dir(root.join("clients")).expect("Mint client directory creates"); - fs::create_dir(root.join("public-keys")).expect("Mint public-key directory creates"); - fs::create_dir(root.join("secrets")).expect("Mint secret directory creates"); - - let (service_public, service_private) = mint_service_key_pair(9); - let public_file = format!( - "{}.jwk.json", - service_public["kid"] - .as_str() - .expect("Mint service key has an id") - ); - fs::write( - root.join("public-keys").join(&public_file), - service_public.to_string(), - ) - .expect("Mint governed public key writes"); - write_owner_only( - &root.join("secrets/signing.jwk"), - service_private.to_string().as_bytes(), - ); - write_owner_only( - &root.join("secrets/audit-hmac-key"), - b"0123456789abcdef0123456789abcdef", - ); + let audience = audience.to_owned(); + tokio::task::spawn_blocking(move || Self::start(&audience)) + .await + .unwrap() + } - let (client_private, client_public) = fixtures::ed25519_pair(); - let client_public = - serde_json::to_value(client_public).expect("Mint client public key serializes"); - fs::write( - root.join("clients/relay-consumer.yaml"), - format!( - "clientId: relay-consumer\nprincipal: synthetic-social-caseworker\nauthorization:\n scopes: [registry:social-assistance:caseworker]\n claims:\n purpose: benefit-delivery\n service_area: AREA-A\nkeys: [{client_public}]\n" - ), + fn start(audience: &str) -> Self { + let reservation = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = reservation.local_addr().unwrap().port(); + let issuer = format!("http://127.0.0.1:{port}"); + let temp = tempfile::tempdir().unwrap(); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let label = format!("relay-acceptance-{port}"); + let id = format!( + "relay-acceptance-{port}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let (public, private) = issuer_client_key_pair(11); + let description = local::local_description( + SessionIdentity { + label: label.clone(), + id: id.clone(), + }, + port, + temp.path().join("issuer"), + audience.to_owned(), + vec![LocalClient { + client_id: "relay-consumer".into(), + public_jwks: json!({"keys":[public]}).to_string(), + claims: BTreeMap::from([ + ("purpose".into(), "benefit-delivery".into()), + ("service_area".into(), "AREA-A".into()), + ("registry_actor_kind".into(), "service".into()), + ]), + scopes: vec!["registry:social-assistance:caseworker".into()], + allow_human_fixture: false, + }], ) - .expect("Mint Relay registration writes"); - - let config_path = root.join("mint.yaml"); - fs::write( - &config_path, - format!( - r#"version: 1 -validationMode: supervised-local-development -issuer: {issuer} -listener: {{address: 127.0.0.1, port: {}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{audience}] - lifetimeSeconds: 300 -clientAssertion: - audience: {token_endpoint} - algorithms: [EdDSA] -clients: - directory: clients -"#, - address.port(), - root.join("secrets").display(), - ), + .expect("stock issuer description"); + render::render(&description).expect("stock issuer render"); + let image = ThunderIdPin::load().expect("stock issuer pin").image; + let docker = std::env::var_os("DOCKER_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|path| path.join("docker")) + .find(|path| path.is_file()) + .expect("Docker is required for the stock issuer acceptance gate") + }); + drop(reservation); + local::start( + &Session { + label: &label, + id: &id, + port, + state_root: &temp.path().join("issuer"), + image: &image, + }, + &docker, + &mut || false, ) - .expect("Mint Relay deployment writes"); - - let config = MintConfig::load(&config_path).expect("Mint Relay configuration loads"); - let service = Arc::new( - MintService::load(config) - .await - .expect("Mint Relay deployment loads"), - ); - let app = build_mint_app(service); - let listener = tokio::net::TcpListener::from_std(listener) - .expect("Mint acceptance listener transfers to Tokio"); - let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tokio::spawn(async move { - axum::serve(listener, app) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await - }); - - let http = reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(1)) - .build() - .expect("Mint acceptance readiness client builds"); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if http - .get(format!("{issuer}/ready")) - .send() - .await - .is_ok_and(|response| response.status().is_success()) - { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("Mint becomes ready before the acceptance deadline"); - - let provider: Arc = Arc::new( - PrivateKeyJwt::new(PrivateKeyJwtConfig::new( - url::Url::parse(&token_endpoint).expect("Mint token endpoint parses"), + .expect("stock issuer starts"); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{issuer}/oauth2/token").parse().unwrap(), "relay-consumer", - client_private, - )) - .expect("Mint private-key-JWT provider builds"), - ); - + registry_platform_crypto::PrivateJwk::parse(&private.to_string()).unwrap(), + ) + .with_audience(&issuer) + .with_resource(audience) + .with_scopes(["registry:social-assistance:caseworker"]), + ) + .expect("registered private-key-JWT provider"); Self { issuer, - token_provider: provider, - shutdown, - server, - _temp: temp, + token_provider: Arc::new(provider), + label, + id, + port, + image, + docker, + temp, } } async fn stop(self) { - self.shutdown - .send(()) - .expect("Mint acceptance server is running"); - tokio::time::timeout(Duration::from_secs(5), self.server) + tokio::task::spawn_blocking(move || drop(self)) .await - .expect("Mint acceptance server shuts down before timeout") - .expect("Mint acceptance server task completes") - .expect("Mint acceptance server shuts down cleanly"); + .unwrap(); + } +} +impl Drop for StockIssuerLoopback { + fn drop(&mut self) { + let _ = local::stop( + &Session { + label: &self.label, + id: &self.id, + port: self.port, + state_root: &self.temp.path().join("issuer"), + image: &self.image, + }, + &self.docker, + ); } } -fn mint_service_key_pair(seed: u8) -> (Value, Value) { +fn issuer_client_key_pair(seed: u8) -> (Value, Value) { let scalar = [seed; 32]; - let signing = - p256::ecdsa::SigningKey::from_slice(&scalar).expect("the Mint acceptance scalar is valid"); + let signing = p256::ecdsa::SigningKey::from_slice(&scalar) + .expect("the issuer acceptance scalar is valid"); let encoded = signing.verifying_key().to_encoded_point(false); let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("an uncompressed point has x")); let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("an uncompressed point has y")); let public = PublicJwk::parse( &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), ) - .expect("the Mint acceptance public key parses"); - let kid = public.jkt().expect("the Mint service thumbprint computes"); + .expect("the issuer acceptance public key parses"); + let kid = public.jkt().expect("the issuer client thumbprint computes"); ( json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, @@ -352,12 +311,6 @@ fn mint_service_key_pair(seed: u8) -> (Value, Value) { ) } -fn write_owner_only(path: &Path, contents: &[u8]) { - fs::write(path, contents).expect("Mint acceptance secret writes"); - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) - .expect("Mint acceptance secret becomes owner-only"); -} - fn complete(outcome: Conditional, operation: &str) -> registry_relay_client::Complete { match outcome { Conditional::Complete(value) => value, @@ -1102,17 +1055,11 @@ async fn rust_client_drives_the_real_relay_router_across_the_public_surface() { } #[tokio::test] -async fn mint_registered_authority_drives_a_protected_relay_lookup() { +#[ignore = "exact gate: starts the pinned stock issuer container"] +async fn stock_issuer_registered_authority_drives_a_protected_relay_lookup() { let mut relay = ProjectHarness::open("social-assistance").await; - let audience = relay - .runtime - .authentication - .issuer - .as_ref() - .expect("social-assistance declares an issuer") - .audience - .clone(); - let mint = MintLoopback::start_social_assistance(&audience).await; + let audience = "urn:registrystack:relay:acceptance:social-assistance".to_owned(); + let stock = StockIssuerLoopback::start_social_assistance(&audience).await; if let Some(fixture_idp) = relay.idp.take() { fixture_idp.stop().await; @@ -1123,15 +1070,16 @@ async fn mint_registered_authority_drives_a_protected_relay_lookup() { .issuer .clone() .expect("social-assistance declares an issuer"); - issuer.discovery_url = Some(format!("{}/.well-known/openid-configuration", mint.issuer)); - issuer.algorithms = vec!["ES256".into()]; + issuer.discovery_url = Some(format!("{}/.well-known/openid-configuration", stock.issuer)); + issuer.audience = audience; + issuer.algorithms = vec!["RS256".into()]; let authenticator = build_authenticator_for_supervised_local_development(&issuer) .await - .expect("Relay startup discovers Mint and loads its signing key"); + .expect("Relay startup discovers the stock issuer and loads its signing key"); relay.replace_authenticator(authenticator); let loopback = - ClientLoopback::start_with_provider(&relay, Some(Arc::clone(&mint.token_provider))).await; + ClientLoopback::start_with_provider(&relay, Some(Arc::clone(&stock.token_provider))).await; let request = LookupRequest::default() .options( RecordOptions::default() @@ -1149,8 +1097,8 @@ async fn mint_registered_authority_drives_a_protected_relay_lookup() { .client .lookup_record("assistance-enrolment", "by-case-and-person", &request, None) .await - .expect("Mint-authorized Relay lookup succeeds"), - "Mint-authorized Relay lookup", + .expect("Stock-issuer-authorized Relay lookup succeeds"), + "Stock-issuer-authorized Relay lookup", ); match result.value { RecordResponse::Json(record) => { @@ -1167,7 +1115,7 @@ async fn mint_registered_authority_drives_a_protected_relay_lookup() { } loopback.stop().await; - mint.stop().await; + stock.stop().await; } #[tokio::test] From 3a18101feef79f258d5afa7e6762a1f349c7f887 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:53:06 +0700 Subject: [PATCH 010/120] test(casework): prove stock issuer task exchange with BREG Signed-off-by: Jeremi Joslin --- .../src/task_grants/native_exchange_tests.rs | 627 +++++++++++++++++- .../src/task_grants/native_resource.rs | 75 +-- 2 files changed, 630 insertions(+), 72 deletions(-) diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 07d525b0ed..94af8b0a04 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -1,21 +1,28 @@ //! Actual Casework approval -> native RFC 8693 exchange -> BREG PostgreSQL mutation. //! Credentials and protected response bodies stay in memory and never enter logs or argv. +//! Set the two disposable database variables named by the ignore reason, then run +//! `cargo test --locked -p registry-casework --features postgres-test --lib +//! approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_writes -- --ignored`. use super::*; use async_trait::async_trait; -use axum::{body::{to_bytes, Body}, http::{Request, StatusCode}, Router}; +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use registry_casework_core::*; use registry_platform_config::{SecretProvider, SecretResolver}; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; -use registry_thunderid_tooling::{description::*, local, render, container::Session}; -use std::{collections::BTreeMap, sync::{Arc, atomic::{AtomicUsize,Ordering}}, time::Duration, path::Path, os::unix::fs::PermissionsExt}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD,Engine}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_thunderid_tooling::{container::Session, description::*, local, render}; +use std::{collections::BTreeMap, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; #[path = "native_resource.rs"] mod resource; const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; const BREG_RESOURCE: &str = "urn:breg:task-test"; const AUTHORITY: &str = "https://casework.example"; -const IMAGE: &str = "sha256:9f16ec5995a5d23220055fba152f7d8d32fdde5083f87866fbe345b53810ce79"; + fn binding() -> SourceBinding { SourceBinding { source_revision: "1".into(), @@ -24,9 +31,8 @@ fn binding() -> SourceBinding { generation: "generation-1".into(), } } -struct Source { - mode: Arc, -} +/// Synthetic source fixture: only this governed selector is available. +struct Source; #[async_trait] impl SourceAdapter for Source { fn source_id(&self) -> &str { @@ -72,25 +78,12 @@ impl SourceAdapter for Source { &self, _: &SubjectRef, fields: &[String], - caller: Option<(&str, EphemeralCredential<'_>)>, + _: Option<(&str, EphemeralCredential<'_>)>, ) -> Result { assert_eq!(fields, &["tenant"]); - match self.mode.load(Ordering::SeqCst) { - 1 => return Err(SourceAdapterError::Unavailable), - 3 if caller.is_some() => return Err(SourceAdapterError::Denied), - 4 if caller.is_none() => { - tokio::time::sleep(std::time::Duration::from_millis(1250)).await - } - _ => (), - } - let person = if self.mode.load(Ordering::SeqCst) == 2 { - "different-person" - } else { - "tenant-a" - }; Ok(TaskSubjectContext { binding: binding(), - values: std::collections::BTreeMap::from([("tenant".into(), json!(person))]), + values: std::collections::BTreeMap::from([("tenant".into(), json!("tenant-a"))]), }) } async fn prepare_action( @@ -107,3 +100,589 @@ impl SourceAdapter for Source { } } +fn key(kid: &str) -> registry_platform_crypto::PrivateJwk { + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + key.kid = Some(kid.into()); + key +} +fn client( + id: &str, + key: ®istry_platform_crypto::PrivateJwk, + kind: Option<&str>, + scopes: &[&str], +) -> local::LocalClient { + local::LocalClient { + client_id: id.into(), + public_jwks: json!({"keys":[key.public()]}).to_string(), + claims: kind + .map(|kind| BTreeMap::from([("registry_actor_kind".into(), kind.into())])) + .unwrap_or_default(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + allow_human_fixture: kind == Some("human"), + } +} +struct Issuer { + description: IssuerDescription, + image: String, + jwks: Value, +} +impl Issuer { + fn session(&self) -> Session<'_> { + Session { + label: &self.description.session.label, + id: &self.description.session.id, + port: self.description.port, + state_root: &self.description.state_root, + image: &self.image, + } + } + fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.description.port) + } +} +impl Drop for Issuer { + fn drop(&mut self) { + let _ = local::stop(&self.session(), Path::new("docker")); + } +} +fn start_issuer( + root: &Path, + casework_port: u16, + human: ®istry_platform_crypto::PrivateJwk, + agent: ®istry_platform_crypto::PrivateJwk, + status: ®istry_platform_crypto::PrivateJwk, + seed: ®istry_platform_crypto::PrivateJwk, +) -> Issuer { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let id = Uuid::new_v4().to_string(); + let session = SessionIdentity { + label: format!("institutional-task-{}", &id[..12]), + id, + }; + let mut description = local::local_description( + session.clone(), + port, + root.into(), + CASEWORK_RESOURCE.into(), + vec![ + client("human-client", human, Some("human"), &["casework:staff"]), + client( + "task-agent", + agent, + Some("agent"), + &["casework:grants:assert"], + ), + client( + "breg-status", + status, + Some("service"), + &["casework:grants:status"], + ), + ], + ) + .unwrap(); + let mut seed_client = client("seed-client", seed, None, &["records:get"]); + seed_client.claims = BTreeMap::from([ + ("tenant_claim".into(), "tenant-a".into()), + ("registry_purpose".into(), "maintain".into()), + ]); + let target = local::local_description( + SessionIdentity { + label: session.label.clone(), + id: Uuid::new_v4().to_string(), + }, + port, + root.into(), + BREG_RESOURCE.into(), + vec![seed_client], + ) + .unwrap(); + description.resource_servers.extend(target.resource_servers); + description.roles.extend(target.roles); + description.machine_clients.extend(target.machine_clients); + description + .schema_attributes + .extend(target.schema_attributes); + description.schema_attributes.sort(); + description.schema_attributes.dedup(); + let authority_server = description.resource_servers[0].id.clone(); + description + .machine_clients + .iter_mut() + .find(|c| c.client_id == "task-agent") + .unwrap() + .token_exchange = Some(TokenExchangeClient { + assertion_resource_server_id: authority_server, + assertion_scope: "casework:grants:assert".into(), + }); + description.exchange_issuers.push(ExchangeIssuer { + id: Uuid::new_v4().to_string(), + name: "Casework task authority".into(), + issuer: AUTHORITY.into(), + jwks_endpoint: format!("http://host.docker.internal:{casework_port}/.well-known/jwks.json"), + }); + std::fs::create_dir_all(root.join("secrets")).unwrap(); + for name in ["direct_auth_secret", "throwaway-bootstrap-password"] { + let path = root.join("secrets").join(name); + std::fs::write(&path, Uuid::new_v4().to_string()).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + render::render(&description).unwrap(); + let pin = registry_thunderid_tooling::version::ThunderIdPin::load().unwrap(); + let mut issuer = Issuer { + description, + image: pin.image, + jwks: Value::Null, + }; + issuer.jwks = local::start(&issuer.session(), Path::new("docker"), &mut || false).unwrap(); + issuer +} +fn provider( + issuer: &str, + client: &str, + key: ®istry_platform_crypto::PrivateJwk, + resource: &str, + scope: &str, +) -> PrivateKeyJwt { + PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{issuer}/oauth2/token").parse().unwrap(), + client, + key.clone(), + ) + .with_audience(issuer) + .with_resource(resource) + .with_scopes([scope]), + ) + .unwrap() +} +fn bearer(token: ®istry_platform_httputil::BearerToken) -> String { + token + .authorization_header_value() + .to_str() + .unwrap() + .strip_prefix("Bearer ") + .unwrap() + .to_owned() +} +fn payload(token: &str) -> Value { + serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(token.split('.').nth(1).unwrap()) + .unwrap(), + ) + .unwrap() +} +struct Fixture { + app: Router, + item: Uuid, + admin: tokio_postgres::Client, + schema: String, +} +async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> Fixture { + let base = std::env::var("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL") + .expect("disposable Casework database required"); + let schema = format!("native_task_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .unwrap(); + tokio::spawn(async move { + let _ = connection.await; + }); + admin + .batch_execute(&format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + )) + .await + .unwrap(); + let separator = if base.contains('?') { '&' } else { '?' }; + let url = format!("{base}{separator}options=-csearch_path%3D{schema}"); + let name = format!("NATIVE_TASK_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&name, url); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp").unwrap(); + let db_config = crate::DatabaseConfig { + runtime_url_ref: format!("secret:env/{name}"), + migration_url_ref: format!("secret:env/{name}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + let store = PostgresStore::connect_migration(&db_config, &secrets).unwrap(); + store.migrate().await.unwrap(); + std::env::remove_var(name); + let human = issuer + .description + .machine_clients + .iter() + .find(|c| c.client_id == "human-client") + .unwrap() + .agent_id + .clone(); + let agent = issuer + .description + .machine_clients + .iter() + .find(|c| c.client_id == "task-agent") + .unwrap() + .agent_id + .clone(); + let breg: Value = serde_json::from_str(resource::PROJECT).unwrap(); + let operations = breg["accessProfiles"][1]["permissions"][0]["operations"].clone(); + let template:TaskTemplate=serde_json::from_value(json!({"id":"draft","version":"1","label":"Prepare correction draft","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":BREG_RESOURCE,"purpose":"review","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"correction-requests","operations":operations}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); + store + .activate_task_templates(&project.task_templates) + .await + .unwrap(); + admin + .execute( + "INSERT INTO casework_teams(team_id,revision) VALUES('team',1)", + &[], + ) + .await + .unwrap(); + admin.execute("INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,$2,'staff')",&[&issuer.url(),&human]).await.unwrap(); + admin.execute("INSERT INTO casework_queue_service(queue_id,team_id,revision) VALUES('review','team',1)",&[]).await.unwrap(); + let item = Uuid::new_v4(); + admin.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,holder_issuer,holder_subject,revision,first_observed_at,updated_at) VALUES($1,'source','request','synthetic-request','review','review-1',$2,'claimed','review',$3,$4,1,now(),now())",&[&item,&serde_json::to_value(binding()).unwrap(),&issuer.url(),&human]).await.unwrap(); + let authority = TaskAuthority { + config: crate::TaskAuthorityConfig { + id: "casework".into(), + issuer: AUTHORITY.into(), + exchange_audience: issuer.url(), + signing_key_ref: "secret:env/UNUSED_IN_MEMORY_KEY".into(), + status_clients: BTreeMap::from([("breg-status".into(), BREG_RESOURCE.into())]), + }, + key, + }; + let service = crate::CaseworkService::new( + store, + project.clone(), + [Arc::new(Source) as Arc], + ) + .unwrap() + .with_task_authority(Some(authority)); + let verifier = TokenVerifierConfig::access_token_profile( + issuer.url(), + vec![CASEWORK_RESOURCE.into()], + vec![jsonwebtoken::Algorithm::RS256], + vec!["at+jwt".into()], + ) + .with_scope_claim("scope") + .with_allowed_clients(vec![ + "human-client".into(), + "task-agent".into(), + "breg-status".into(), + ]); + let auth = crate::CaseworkAuthenticator::new( + &project, + verifier, + Arc::new(JwksFetcher::new_static( + serde_json::from_value(issuer.jwks.clone()).unwrap(), + JwksFetcherConfig::defaults(), + )), + crate::HumanIdentityConfig::default(), + ); + Fixture { + app: crate::router(crate::HttpState { + service, + authenticator: Arc::new(auth), + project: Arc::new(project), + }), + item, + admin, + schema, + } +} +async fn request( + app: &Router, + method: &str, + path: &str, + token: &str, + human: bool, + body: Option, + key: Option<&str>, +) -> (StatusCode, Value) { + use tower::ServiceExt; + let mut req = Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")); + if human { + req = req + .header(CASEWORK_PROFILE_HEADER, "staff") + .header(SOURCE_PROFILE_HEADER, "source-reader"); + } + if let Some(key) = key { + req = req + .header(IF_MATCH_HEADER, "\"1\"") + .header(IDEMPOTENCY_KEY_HEADER, key); + } + let data = if let Some(body) = body { + req = req.header("content-type", "application/json"); + Body::from(body.to_string()) + } else { + Body::empty() + }; + let response = app.clone().oneshot(req.body(data).unwrap()).await.unwrap(); + ( + response.status(), + serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap(), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires Docker plus disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL"] +async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_writes() { + let root = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); + let casework_port = listener.local_addr().unwrap().port(); + let (human_key, agent_key, status_key, seed_key) = + (key("human"), key("agent"), key("status"), key("seed")); + let (h, a, s, d) = ( + human_key.clone(), + agent_key.clone(), + status_key.clone(), + seed_key.clone(), + ); + let path = root.path().to_path_buf(); + let issuer = + tokio::task::spawn_blocking(move || start_issuer(&path, casework_port, &h, &a, &s, &d)) + .await + .unwrap(); + let f = fixture(&issuer, key("casework-task-authority")).await; + let serving = f.app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, serving).await.unwrap(); + }); + let human = bearer( + &provider( + &issuer.url(), + "human-client", + &human_key, + CASEWORK_RESOURCE, + "casework:staff", + ) + .bearer_token() + .await + .unwrap(), + ); + let base = format!("/v1/work-items/{}/task-grants", f.item); + let (code, preview) = request( + &f.app, + "GET", + &format!("/v1/work-items/{}/task-templates", f.item), + &human, + true, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + assert_eq!( + preview["templates"][0]["subjects"]["tenant_claim"], + "tenant-a" + ); + let (code, grant) = request( + &f.app, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"draft","templateVersion":"1"})), + Some("native-approval"), + ) + .await; + assert_eq!(code, StatusCode::OK); + let grant_id = grant["id"].as_str().unwrap(); + let bootstrap = bearer( + &provider( + &issuer.url(), + "task-agent", + &agent_key, + CASEWORK_RESOURCE, + "casework:grants:assert", + ) + .bearer_token() + .await + .unwrap(), + ); + assert!(payload(&bootstrap).get("registry_grant_id").is_none()); + let (code, issued) = request( + &f.app, + "POST", + &format!("/v1/task-grants/{grant_id}/assertion"), + &bootstrap, + false, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + let assertion = issued["assertion"].as_str().unwrap(); + let exchange = provider( + &issuer.url(), + "task-agent", + &agent_key, + BREG_RESOURCE, + "records:get", + ); + let token = bearer(&exchange.exchange(assertion).await.unwrap()); + let claims = payload(&token); + assert_eq!(claims["registry_grant_id"], grant_id); + assert_eq!(claims["registry_grant_source_issuer"], AUTHORITY); + assert_eq!(claims["registry_grant_exp"], issued["grantExpiresAt"]); + assert_eq!(claims["registry_grant_exp"], grant["expiresAt"]); + assert_eq!(claims["identity"], json!({"tenant_claim":"tenant-a"})); + assert_eq!(claims["registry_grant_bounds"], grant["bounds"]); + assert_eq!(claims["scope"], "records:get"); + assert_eq!(claims["registry_purpose"], "review"); + assert_eq!(claims["registry_grant_client"], "task-agent"); + assert_eq!(claims["registry_grant_resource"], BREG_RESOURCE); + assert!(claims["exp"].as_u64().unwrap() <= claims["iat"].as_u64().unwrap() + 300); + let reexchanged = bearer(&exchange.exchange(&token).await.unwrap()); + let repeated = payload(&reexchanged); + for name in [ + "registry_actor_kind", + "registry_grant_id", + "registry_grant_authority", + "registry_grant_source_issuer", + "registry_grant_client", + "registry_grant_resource", + "registry_grant_exp", + "registry_grant_bounds", + "registry_purpose", + "identity", + "scope", + ] { + assert_eq!( + repeated[name], claims[name], + "re-exchange must preserve {name}" + ); + } + let db = resource::TestDatabase::create(8).await; + let registry = Arc::new( + registry_breg::compile_project( + ®istry_breg::parse_project_json(resource::PROJECT.as_bytes()).unwrap(), + &[], + registry_breg::CompileProfile::Authoring, + ) + .unwrap(), + ); + let identity = resource::install(&db, ®istry).await; + let checker = Arc::new( + registry_breg::task_grant::TaskGrantStatusClient::new( + "casework".into(), + AUTHORITY.into(), + BREG_RESOURCE.into(), + format!("http://127.0.0.1:{casework_port}").parse().unwrap(), + Arc::new(provider( + &issuer.url(), + "breg-status", + &status_key, + CASEWORK_RESOURCE, + "casework:grants:status", + )), + None, + ) + .unwrap(), + ); + let app = resource::app( + &db, + registry, + identity, + &issuer.url(), + issuer.jwks.clone(), + checker, + ); + let seed = bearer( + &provider( + &issuer.url(), + "seed-client", + &seed_key, + BREG_RESOURCE, + "records:get", + ) + .bearer_token() + .await + .unwrap(), + ); + let old = resource::create( + &app, + "/v1/records/sites?accessProfile=steward", + &seed, + "old", + json!({"tenant":"tenant-a","name":"old"}), + ) + .await; + let next = resource::create( + &app, + "/v1/records/sites?accessProfile=steward", + &seed, + "next", + json!({"tenant":"tenant-a","name":"next"}), + ) + .await; + let placement = resource::create( + &app, + "/v1/records/placements?accessProfile=steward", + &seed, + "placement", + json!({"tenant":"tenant-a","site":resource::id(&old)}), + ) + .await; + let draft=resource::create(&app,"/v1/records/correction-requests?accessProfile=submitter",&token,"native-draft",json!({"tenant":"tenant-a","placement":resource::id(&placement),"proposedSite":resource::id(&next),"reason":"Synthetic task correction"})).await; + let id = resource::id(&draft); + let read = resource::get(&app, &id, "submitter", &token).await; + let before = resource::counts(&db).await; + let (code, _) = request( + &f.app, + "POST", + &format!("{base}/{grant_id}/revoke"), + &human, + true, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + let refused = resource::send( + &app, + axum::http::Method::PATCH, + &format!("/v1/records/correction-requests/{id}?accessProfile=submitter"), + &token, + Some("revoked-write"), + Some(&read.etag), + json!([{"op":"replace","path":"/data/reason","value":"Must not persist"}]), + ) + .await; + assert_eq!(refused.status, StatusCode::PRECONDITION_FAILED); + assert_eq!(resource::counts(&db).await, before); + let (code, _) = request( + &f.app, + "POST", + &format!("/v1/task-grants/{grant_id}/assertion"), + &bootstrap, + false, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::FORBIDDEN); + drop(app); + db.cleanup().await; + server.abort(); + drop(f.app); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); + tokio::task::spawn_blocking(move || drop(issuer)) + .await + .unwrap(); +} diff --git a/crates/registry-casework/src/task_grants/native_resource.rs b/crates/registry-casework/src/task_grants/native_resource.rs index 6bb13029ef..e41ecc80ad 100644 --- a/crates/registry-casework/src/task_grants/native_resource.rs +++ b/crates/registry-casework/src/task_grants/native_resource.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! Synthetic governed-request fixture for the complete native task journey. -#![allow(dead_code, unused_imports)] #[path = "../../../registry-breg/tests/support/postgres_harness.rs"] +#[allow(dead_code)] mod postgres_harness; use axum::{ body::{to_bytes, Body}, @@ -19,28 +19,17 @@ use registry_breg::postgres::{ PostgresRecordMutationService, PostgresRecordReadService, RegistryLockKey, RegistryStateTestIdentity, }; -use registry_breg::task_grant::{TaskGrantBinding, TaskGrantError, TaskGrantStatusChecker}; -use registry_breg::{compile_project, parse_project_json, CompileProfile, CompiledRegistry}; +use registry_breg::task_grant::TaskGrantStatusChecker; +use registry_breg::CompiledRegistry; use registry_platform_audit::AuditProfile; -use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; -use registry_platform_testing::{oidc_verifier_config, MockIdp}; use serde_json::{json, Value}; -use std::{ - collections::BTreeMap, - sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex, - }, - time::Duration, -}; +use std::{sync::Arc, time::Duration}; use tower::ServiceExt; -use uuid::Uuid; use zeroize::Zeroizing; const PACKAGE: &str = "task-authority-http"; const AUDIENCE: &str = "urn:breg:task-test"; const REVISION: &str = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const SOURCE: &str = "https://casework.example"; pub(super) const PROJECT: &str = r#"{ "apiVersion":"registry.registrystack.org/v1alpha1", "kind":"RegistryProject", @@ -167,7 +156,10 @@ impl ReadinessProbe for Ready { Box::pin(async { true }) } } -pub(super) async fn install(db: &TestDatabase, registry: &CompiledRegistry) -> ExpectedRegistryIdentity { +pub(super) async fn install( + db: &TestDatabase, + registry: &CompiledRegistry, +) -> ExpectedRegistryIdentity { let (migration, task) = db.connect_migration().await; install_compiled_schema(&migration, registry, &db.runtime_role) .await @@ -225,8 +217,18 @@ pub(super) fn app( ) .with_task_status(status), ); - let keys = Arc::new(JwksFetcher::new_static(serde_json::from_value(jwks).unwrap(), JwksFetcherConfig::defaults())); - let verifier = registry_platform_oidc::TokenVerifierConfig::access_token_profile(issuer, vec![AUDIENCE.into()], vec![jsonwebtoken::Algorithm::RS256], vec!["JWT".into(), "at+jwt".into()]).with_scope_claim("scope").with_allowed_clients(vec!["task-agent".into(),"seed-client".into()]); + let keys = Arc::new(JwksFetcher::new_static( + serde_json::from_value(jwks).unwrap(), + JwksFetcherConfig::defaults(), + )); + let verifier = registry_platform_oidc::TokenVerifierConfig::access_token_profile( + issuer, + vec![AUDIENCE.into()], + vec![jsonwebtoken::Algorithm::RS256], + vec!["at+jwt".into()], + ) + .with_scope_claim("scope") + .with_allowed_clients(vec!["task-agent".into(), "seed-client".into()]); let auth = RegistryAuthenticator::new( ®istry, verifier, @@ -301,7 +303,13 @@ pub(super) async fn send( .unwrap(); Response { status, etag, body } } -pub(super) async fn create(app: &Router, route: &str, token: &str, key: &str, data: Value) -> Response { +pub(super) async fn create( + app: &Router, + route: &str, + token: &str, + key: &str, + data: Value, +) -> Response { let r = send( app, Method::POST, @@ -335,35 +343,6 @@ pub(super) async fn get(app: &Router, id: &str, profile: &str, token: &str) -> R assert_eq!(r.status, StatusCode::OK, "{}", r.body); r } -fn action(r: &Response, operation: &str) -> Value { - r.body["data"]["request"]["actions"] - .as_array() - .unwrap() - .iter() - .find(|a| a["operation"] == operation) - .unwrap() - .clone() -} -async fn perform(app: &Router, action: &Value, token: &str, key: &str) -> Response { - let mut body = if action.get("proposalVersion").is_some() { - json!({"proposalVersion":action["proposalVersion"],"effectDigest":action["effectDigest"]}) - } else { - json!({}) - }; - if action["operation"] == "reject_request" { - body["reason"] = json!("The task was revoked."); - } - send( - app, - Method::POST, - action["href"].as_str().unwrap(), - token, - Some(key), - action["ifMatch"].as_str(), - body, - ) - .await -} pub(super) async fn counts(db: &TestDatabase) -> Vec { let mut counts = Vec::new(); for table in [ From 208d7a55c65cff5e99e8037407821d11acc08c9b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:53:25 +0700 Subject: [PATCH 011/120] test(evidence): replace wallet issuer acceptance Signed-off-by: Jeremi Joslin --- .../tests/inji_interoperability.rs | 58 +++++++++++++++++++ ...uthorization.rs => offer_authorization.rs} | 48 +++++++-------- .../contracts/security-test-traceability.yaml | 8 ++- 3 files changed, 83 insertions(+), 31 deletions(-) rename crates/registry-evidence-oid4vci/tests/{mint_offer_authorization.rs => offer_authorization.rs} (91%) diff --git a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs index ada9c3504e..e8867b6fd9 100644 --- a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs +++ b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs @@ -717,6 +717,39 @@ fn offer_access_token(issuer: &FixtureIssuer) -> String { ) } +fn task_bound_offer_access_token(issuer: &FixtureIssuer) -> String { + let now = Utc::now().timestamp(); + signed_jwt( + &issuer.signing_key, + json!({ + "alg": "ES256", + "typ": "at+jwt", + "kid": issuer.signing_public["kid"], + }), + json!({ + "iss": SUPPORT_ORIGIN, + "sub": "tutorial-operator", + "client_id": "tutorial-operator", + "aud": ADOPTER_ORIGIN, + "iat": now, + "exp": now + 300, + "jti": "synthetic-task-bound-offer-authorization", + "registry_actor_kind": "agent", + "registry_grant_id": "synthetic-task-grant", + "registry_grant_authority": "synthetic-casework-authority", + "registry_grant_source_issuer": "https://casework.example.org", + "registry_grant_client": "tutorial-operator", + "registry_grant_resource": ADOPTER_ORIGIN, + "registry_purpose": PURPOSE, + "registry_grant_exp": now + 300, + "registry_grant_bounds": { + "type": "evidence", + "requirement": CONFIGURATION_ID, + }, + }), + ) +} + fn adapter_binary() -> PathBuf { std::env::var_os("EVIDENCE_OID4VCI_BIN") .map(PathBuf::from) @@ -929,6 +962,31 @@ async fn copied_config_checks_starts_and_completes_the_real_binary_journey() { .await .expect("authorization metadata is JSON"); + let task_bound = client + .post(format!("{ADOPTER_ORIGIN}{OFFERS_PATH}")) + .bearer_auth(task_bound_offer_access_token(&issuer)) + .json(&json!({ + "credentialConfigurationId": configuration_id, + "subjects": [{ + "role": "primary", + "selectorValues": {"identifier": "synthetic-tutorial-subject"}, + }], + })) + .send() + .await + .expect("task-bound offer refusal responds"); + assert_eq!(task_bound.status(), reqwest::StatusCode::UNAUTHORIZED); + let refusal: Value = task_bound + .json() + .await + .expect("task-bound offer refusal is JSON"); + assert_eq!(refusal["error"], "invalid_token"); + assert_eq!( + refusal["error_description"], + "the presented credential was refused" + ); + println!("TASK GRANT REFUSED: deferred wallet state was not created"); + let offer: Value = client .post(format!("{ADOPTER_ORIGIN}{OFFERS_PATH}")) .bearer_auth(offer_access_token(&issuer)) diff --git a/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs b/crates/registry-evidence-oid4vci/tests/offer_authorization.rs similarity index 91% rename from crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs rename to crates/registry-evidence-oid4vci/tests/offer_authorization.rs index ff587683c9..2d501ae0ae 100644 --- a/crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs +++ b/crates/registry-evidence-oid4vci/tests/offer_authorization.rs @@ -12,10 +12,7 @@ //! [`registry_evidence_oid4vci::authorizer`] exists to keep. use std::{ - collections::BTreeMap, - net::TcpListener, - os::unix::fs::PermissionsExt as _, - path::{Path, PathBuf}, + collections::BTreeMap, net::TcpListener, os::unix::fs::PermissionsExt as _, path::PathBuf, sync::Arc, }; @@ -41,22 +38,6 @@ const OFFER_AUDIENCE: &str = "https://delivery.example.org"; const CLIENT_ID: &str = "offer-caller"; const PRINCIPAL: &str = "urn:example:offer-caller"; -/// Deterministic Ed25519 material for the client registration. -fn client_key_pair(seed: u8) -> (PrivateJwk, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let d = URL_SAFE_NO_PAD.encode(seed_bytes); - let kid = format!("client-key-{seed}"); - let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); - let private = PrivateJwk::parse( - &json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}) - .to_string(), - ) - .expect("the private JWK parses"); - (private, public) -} - /// Deterministic P-256 material for the token signing key, so the access token /// this test verifies is ES256 signed exactly as a deployment's would be. fn signing_key_pair(seed: u8) -> (Value, Value) { @@ -92,7 +73,11 @@ fn resource_server(config: &OfferAuthorizationConfig, key_set: &Value) -> MintRe .with_required_scopes(config.required_scopes.clone().unwrap_or_default()) } -fn offer_config(issuer: &str, audience: &str, algorithm: AccessTokenAlgorithm) -> OfferAuthorizationConfig { +fn offer_config( + issuer: &str, + audience: &str, + algorithm: AccessTokenAlgorithm, +) -> OfferAuthorizationConfig { OfferAuthorizationConfig { issuer: issuer.to_owned(), jwks_uri: format!("{issuer}/oauth2/jwks"), @@ -226,13 +211,20 @@ async fn an_ordinary_service_offer_with_purpose_but_no_grant_remains_supported() #[tokio::test] async fn a_token_issued_for_another_resource_server_is_refused() { let (token, key_set) = signed_offer_fixture(json!({})); - // The same real token, presented to a deployment that answers to a + // The same correctly signed token, presented to a deployment that answers to a // different audience. An adopter's token for another resource server is // not an offer authorization here. assert_eq!( - resource_server(&offer_config(FIXTURE_ISSUER, "https://elsewhere.example.org", AccessTokenAlgorithm::ES256), &key_set) - .authorize(&token) - .await, + resource_server( + &offer_config( + FIXTURE_ISSUER, + "https://elsewhere.example.org", + AccessTokenAlgorithm::ES256 + ), + &key_set + ) + .authorize(&token) + .await, Err(AuthorizationError::Refused) ); } @@ -290,7 +282,7 @@ fn installed_or_env(variable: &str, binary: &str) -> PathBuf { .unwrap_or_else(|| panic!("set {variable} for the exact stock-issuer gate")) } -/// Replacement acceptance for the former in-process Mint fixture. This gate +/// Replacement acceptance for the former in-process supporting-issuer fixture. This gate /// uses the installed pinned issuer, its native private-key-JWT registration, /// its real token endpoint, and the exact public RS256 keys it served. #[test] @@ -337,8 +329,8 @@ fn stock_issuer_service_token_authorizes_the_offer_boundary() { image: &pin.image, }; drop(reservation); - let keys = local::start(&session, &docker, &mut || false) - .expect("the pinned stock issuer starts"); + let keys = + local::start(&session, &docker, &mut || false).expect("the pinned stock issuer starts"); let _owned = OwnedStockSession { label, id, diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index e7bd9decb5..3fb5570a03 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -469,9 +469,11 @@ entries: tests: - {file: crates/registry-evidence-oid4vci/src/service.rs, name: an_offer_without_authorization_is_refused} - {file: crates/registry-evidence-oid4vci/src/service.rs, name: an_unauthorized_offer_refusal_describes_nothing_this_deployment_offers} - - {file: crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs, name: a_token_mint_issued_authorizes_an_offer} - - {file: crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs, name: a_token_mint_issued_for_another_resource_server_is_refused} - - {file: crates/registry-evidence-oid4vci/tests/mint_offer_authorization.rs, name: a_token_this_issuer_did_not_sign_is_refused} + - {file: crates/registry-evidence-oid4vci/tests/offer_authorization.rs, name: an_ordinary_service_offer_with_purpose_but_no_grant_remains_supported} + - {file: crates/registry-evidence-oid4vci/tests/offer_authorization.rs, name: stock_issuer_service_token_authorizes_the_offer_boundary} + - {file: crates/registry-evidence-oid4vci/tests/offer_authorization.rs, name: task_bound_and_partial_grants_cannot_create_deferred_wallet_offers} + - {file: crates/registry-evidence-oid4vci/tests/offer_authorization.rs, name: a_token_issued_for_another_resource_server_is_refused} + - {file: crates/registry-evidence-oid4vci/tests/offer_authorization.rs, name: a_token_this_issuer_did_not_sign_is_refused} - id: sec-oid4vci-code-single-use tests: - {file: crates/registry-evidence-oid4vci/src/service.rs, name: a_pre_authorized_code_is_redeemable_once} From 6caea0d7d109727a98b018e760d966de70ac4559 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:53:49 +0700 Subject: [PATCH 012/120] test(evidence): replace client issuer fixture Signed-off-by: Jeremi Joslin --- .../tests/against_a_real_deployment.rs | 574 ++++++++++++------ 1 file changed, 383 insertions(+), 191 deletions(-) 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 839b7f138b..8c81d623bb 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -17,19 +17,22 @@ //! Nothing in the fixture names a source product. //! //! Most cases are their own token issuer: the suite publishes a key set and signs -//! the credentials it presents. The credential-acquisition cases instead run a -//! real authorization server on its own loopback origin and let the client's -//! provider authenticate to it with a signed client assertion, so acquisition, -//! caching, and refusal are proven against a server that enforces the grant -//! rather than against a stub of this crate's making. +//! the credentials it presents. Fast credential-acquisition cases use a strict +//! OAuth protocol fixture to isolate acquisition, caching, and refusal. An +//! ignored exact gate repeats the complete request against the installed pinned +//! stock issuer. use std::{ + collections::{BTreeMap, HashMap}, error::Error, fs, net::TcpListener, os::unix::fs::PermissionsExt as _, path::{Path, PathBuf}, - sync::Arc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, time::Duration, }; @@ -46,20 +49,23 @@ use registry_evidence_client::{ TransportKind, VerificationError, VerifiedAudienceScopedEvidence, EVIDENCE_DEFINITIONS_SCHEMA_V1, }; -use registry_mint::{ - config::MintConfig, - server::{self as mint_server, MintService}, +use registry_platform_crypto::{sign, verify, PrivateJwk, PublicJwk}; +use registry_thunderid_tooling::{ + container::Session, + description::SessionIdentity, + local::{self, TypedLocalClient}, + render, + version::ThunderIdPin, }; -use registry_platform_crypto::{sign, PrivateJwk, PublicJwk}; use serde_json::{json, Value}; use url::Url; use wiremock::{ matchers::{method, path}, - Mock, MockServer, ResponseTemplate, + Mock, MockServer, Request as WiremockRequest, ResponseTemplate, }; /// Vocabulary the tracked acceptance fixture publishes. -const TOKEN_AUDIENCE: &str = "evidence-fixture"; +const TOKEN_AUDIENCE: &str = "urn:registry:evidence:fixture"; const CONFIGURED_TAG: &str = "fixture-agency"; const REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; const FIXTURE_SIGNING_KEY_ID: &str = "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"; @@ -520,9 +526,9 @@ async fn a_response_beyond_the_configured_bound_is_refused() { } /// The whole chain, with no credential this suite signed: the provider proves who -/// it is to a real authorization server with a signed assertion, the server issues -/// an access token, and the deployment accepts that token for an exchange whose -/// response verifies. +/// it is to a protocol-faithful authorization endpoint with a signed assertion, +/// the endpoint issues an access token, and the deployment accepts that token +/// for an exchange whose response verifies. #[tokio::test] async fn an_acquired_credential_completes_a_verified_exchange() { let issuer = start_token_issuer().await; @@ -849,15 +855,15 @@ async fn a_credential_inside_the_refresh_margin_is_replaced() { assert_eq!(format!("{second:?}"), "BearerToken { .. }"); } -/// An ES256 client key authenticates against a real token endpoint and carries a +/// An ES256 client key authenticates against the test token endpoint and carries a /// request all the way to a verified assertion. /// /// This is the key an adopter actually holds: `evidencectl access client add /// --generate-local-key` writes a P-256/ES256 JWK, and the published tutorial /// feeds that file straight into this provider. The client therefore has to sign -/// with what the key states rather than one fixed algorithm, and the proof that -/// it does is a real server accepting the assertion, not a header assertion in a -/// unit test. +/// with what the key states rather than one fixed algorithm. The pinned stock +/// issuer acceptance below repeats that algorithm negotiation with the installed +/// server. #[tokio::test] async fn an_es256_client_key_authenticates_and_carries_a_request() { let issuer = start_token_issuer().await; @@ -890,10 +896,9 @@ async fn an_es256_client_key_authenticates_and_carries_a_request() { } /// A client whose key the authorization server never registered acquires -/// nothing, and the failure is the registered OAuth code. This is proven -/// against the issuer's own audit chain, which records zero credentials -/// issued; it does not observe the Evidence deployment's own request count, -/// only that `discover` returns the token failure before it would reach one. +/// nothing, and the failure is the registered OAuth code. The fixture's release +/// counter remains zero; it does not observe the Evidence deployment's own +/// request count, only that `discover` returns the token failure first. #[tokio::test] async fn an_unregistered_client_key_is_refused_without_detail() { let issuer = start_token_issuer().await; @@ -1174,6 +1179,7 @@ impl Deployment { "sub": PRINCIPAL, "iat": now - 1, "exp": now + 60, + "registry_actor_kind": "service", "evidence_tags": requester_tags, "evidence_audience": RELYING_AUDIENCE, }); @@ -1215,7 +1221,7 @@ async fn start(source_answer: Value) -> Deployment { /// signs the credentials it presents. With one, the deployment fetches keys from /// that origin and only credentials that server issued are accepted. async fn start_trusting(source_answer: Value, external_issuer: Option<&str>) -> Deployment { - start_trusting_with_request_burst(source_answer, external_issuer, 10).await + start_trusting_with_request_burst_and_jwks(source_answer, external_issuer, None, 10).await } /// Start a deployment with an explicit request burst ceiling. @@ -1228,6 +1234,16 @@ async fn start_trusting_with_request_burst( source_answer: Value, external_issuer: Option<&str>, request_burst: u32, +) -> Deployment { + start_trusting_with_request_burst_and_jwks(source_answer, external_issuer, None, request_burst) + .await +} + +async fn start_trusting_with_request_burst_and_jwks( + source_answer: Value, + external_issuer: Option<&str>, + external_jwks_uri: Option<&str>, + request_burst: u32, ) -> Deployment { let source = start_mock_server().await; let auth_key = generate_key(AUTH_KEY_ID); @@ -1248,6 +1264,9 @@ async fn start_trusting_with_request_burst( source.uri() } }; + let issuer_jwks_uri = external_jwks_uri + .map(str::to_owned) + .unwrap_or_else(|| format!("{issuer}/.well-known/jwks.json")); // Matched on method and path only. The request the adapter builds is the // runtime's own contract, proven by the runtime's suite; re-pinning it here @@ -1281,6 +1300,7 @@ async fn start_trusting_with_request_burst( &bundle_root, &source.uri(), &issuer, + &issuer_jwks_uri, &format!("http://127.0.0.1:{port}"), signing_key_id, ); @@ -1417,27 +1437,16 @@ async fn await_readiness( // The token issuer harness // --------------------------------------------------------------------------- -/// One real authorization server, issuing access tokens on loopback for the life -/// of one test. -/// -/// It is the reference issuer for this stack, driven here as an ordinary OAuth 2.0 -/// token endpoint: the provider under test carries nothing specific to it, and any -/// server accepting the `client_credentials` grant with the `private_key_jwt` -/// authentication method would serve. +/// A small protocol fixture for fast client cache and refusal cases. Exact +/// compatibility with the maintained issuer is covered by the ignored stock +/// issuer acceptance below. struct TokenIssuer { origin: String, token_endpoint: Url, - /// The key the registered client signs its assertions with. client_key: PrivateJwk, - /// A second key registered to the same client, signing with ES256. es256_client_key: PrivateJwk, - /// The issuer's own audit chain, which is where a released credential is - /// recorded and therefore how this suite counts what it issued. - audit_path: PathBuf, - shutdown: Option>, - server: tokio::task::JoinHandle>, - /// Held so the deployment on disk outlives the service that reads it. - _directory: tempfile::TempDir, + issued: Arc, + _server: MockServer, } impl TokenIssuer { @@ -1462,181 +1471,357 @@ impl TokenIssuer { client_key: PrivateJwk, refresh_margin_seconds: Option, ) -> Arc { - // The assertion audience is left to its default, which is the token - // endpoint URL. This server requires exactly that, so the default is what - // is under test. let mut config = - PrivateKeyJwtConfig::new(self.token_endpoint.clone(), CLIENT_ID, client_key); + PrivateKeyJwtConfig::new(self.token_endpoint.clone(), CLIENT_ID, client_key) + .with_resource(TOKEN_AUDIENCE) + .with_scopes(["evidence:invoke".to_owned()]); if let Some(seconds) = refresh_margin_seconds { config = config.with_refresh_margin_seconds(seconds); } Arc::new(PrivateKeyJwt::new(config).expect("the provider configuration is usable")) } - /// How many credentials this server has released. - /// - /// The audit chain is written before a credential leaves the endpoint, so a - /// count taken after an exchange has settled includes every release that - /// exchange caused. fn issued_credential_count(&self) -> usize { - let chain = - fs::read_to_string(&self.audit_path).expect("the issuer audit chain is readable"); - chain - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| { - serde_json::from_str::(line).expect("every audit line is one JSON envelope") - }) - .filter(|envelope| envelope["record"]["decision"] == json!("issued")) - .count() + self.issued.load(Ordering::SeqCst) } } -impl Drop for TokenIssuer { - fn drop(&mut self) { - // The temporary deployment the abandoned task reads is removed with this - // struct, and the process ends with the test binary. - stop_service(&mut self.shutdown, &self.server); - } +#[derive(Clone)] +struct ClientCredentialsResponder { + origin: String, + token_endpoint: String, + client_keys: BTreeMap, + signing_key: PrivateJwk, + issued: Arc, } -/// Author, load, and serve one authorization server with a single registered -/// client whose identity matches what the Evidence fixture entitles. -async fn start_token_issuer() -> TokenIssuer { - // Hold the allocation while the matching deployment is authored, and release - // it only immediately before the service binds it. The issuer identity is - // part of that deployment, so the port has to be known first. - let (reservation, port) = reserve_loopback_port().await; - let origin = format!("http://127.0.0.1:{port}"); - let token_endpoint = Url::parse(&format!("{origin}/token")).expect("the token endpoint parses"); +impl ClientCredentialsResponder { + fn invalid_client() -> ResponseTemplate { + ResponseTemplate::new(401).set_body_json(json!({"error":"invalid_client"})) + } - let directory = tempfile::tempdir().expect("temporary issuer root"); - let root = directory.path(); - let secret_root = root.join("secrets"); - fs::create_dir(&secret_root).expect("create the issuer secret root"); - fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)) - .expect("the issuer secret root is owner-only"); - fs::create_dir(root.join("clients")).expect("create the client registry"); - fs::create_dir(root.join("public-keys")).expect("create the issuer public-key directory"); - let (issuer_signing_key, issuer_public_key) = service_key(); - let issuer_key_id = issuer_public_key - .kid - .as_deref() - .expect("the Mint service key has a thumbprint"); - write_secret(&secret_root, "signing.jwk", &issuer_signing_key); - fs::write( - root.join(format!("public-keys/{issuer_key_id}.jwk.json")), - serde_json::to_vec(&issuer_public_key).expect("the issuer public key serializes"), - ) - .expect("write the issuer public key"); - write_secret( - &secret_root, - "audit-hash-key", - "issuer-audit-secret-canary-32-bytes-minimum", - ); + fn assertion_is_valid(&self, request: &WiremockRequest) -> bool { + let form = url::form_urlencoded::parse(&request.body) + .into_owned() + .collect::>(); + if form.get("grant_type").map(String::as_str) != Some("client_credentials") + || form.get("client_assertion_type").map(String::as_str) + != Some("urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + || !matches!( + form.get("resource").map(String::as_str), + None | Some(TOKEN_AUDIENCE) + ) + || !matches!( + form.get("scope").map(String::as_str), + None | Some("evidence:invoke") + ) + { + return false; + } + let Some(assertion) = form.get("client_assertion") else { + return false; + }; + let mut parts = assertion.split('.'); + let (Some(header_segment), Some(payload_segment), Some(signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return false; + }; + let Ok(header): Result = URL_SAFE_NO_PAD + .decode(header_segment) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .ok_or(()) + else { + return false; + }; + let Some(key) = header["kid"] + .as_str() + .and_then(|kid| self.client_keys.get(kid)) + else { + return false; + }; + let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else { + return false; + }; + if verify( + format!("{header_segment}.{payload_segment}").as_bytes(), + &signature, + key, + ) + .is_err() + { + return false; + } + let Ok(claims): Result = URL_SAFE_NO_PAD + .decode(payload_segment) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .ok_or(()) + else { + return false; + }; + let now = Utc::now().timestamp(); + claims["iss"] == CLIENT_ID + && claims["sub"] == CLIENT_ID + && claims["aud"] == self.token_endpoint + && claims["iat"].as_i64().is_some_and(|iat| iat <= now) + && claims["exp"] + .as_i64() + .is_some_and(|exp| now < exp && exp <= now + 300) + && claims["jti"] + .as_str() + .is_some_and(|jti| !jti.is_empty() && jti.len() <= 256) + } + + fn access_token(&self) -> String { + let now = Utc::now().timestamp(); + let header = json!({ + "alg":"ES256", + "kid":self.signing_key.kid, + "typ":"at+jwt", + }); + let claims = json!({ + "iss":self.origin, + "sub":PRINCIPAL, + "client_id":CLIENT_ID, + "aud":TOKEN_AUDIENCE, + "iat":now, + "exp":now + ISSUED_TOKEN_LIFETIME_SECONDS, + "registry_actor_kind":"service", + "evidence_tags":[CONFIGURED_TAG], + "evidence_audience":RELYING_AUDIENCE, + "scope":"evidence:invoke", + }); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header serializes")), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("claims serialize")), + ); + let signature = + sign(input.as_bytes(), &self.signing_key).expect("the fixture issuer signs"); + format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)) + } +} + +impl wiremock::Respond for ClientCredentialsResponder { + fn respond(&self, request: &WiremockRequest) -> ResponseTemplate { + if !self.assertion_is_valid(request) { + return Self::invalid_client(); + } + self.issued.fetch_add(1, Ordering::SeqCst); + ResponseTemplate::new(200).set_body_json(json!({ + "access_token":self.access_token(), + "token_type":"Bearer", + "expires_in":ISSUED_TOKEN_LIFETIME_SECONDS, + })) + } +} - // The registered client carries the principal, the relying-party audience, and - // the requester tag. None of them is anything the client can ask for: the - // deployment reads them from the credential this server issues. +async fn start_token_issuer() -> TokenIssuer { + let server = start_mock_server().await; + let origin = server.uri(); + let token_endpoint = Url::parse(&format!("{origin}/token")).expect("token endpoint parses"); let client_key = generate_key(CLIENT_KEY_ID); - let public_key = - serde_json::to_string(&client_key.public()).expect("the public key serializes"); - // A second registered key for the same client, signing with ES256 rather than - // EdDSA. This is the shape `evidencectl access client add - // --generate-local-key` writes, so registering it here is what proves an - // adopter's own key against a real token endpoint. The assertion's `kid` - // selects between the two. let es256_client_key = generate_es256_key(ES256_CLIENT_KEY_ID); - let es256_public_key = - serde_json::to_string(&es256_client_key.public()).expect("the public key serializes"); - fs::write( - root.join(format!("clients/{CLIENT_ID}.yaml")), - format!( - "clientId: {CLIENT_ID}\nprincipal: {PRINCIPAL}\nevidenceAudience: {RELYING_AUDIENCE}\nrequesterTags: [{CONFIGURED_TAG}]\nkeys: [{public_key}, {es256_public_key}]\n" - ), - ) - .expect("write the client registration"); + let (issuer_signing_key, issuer_public_key) = service_key(); + let signing_key = PrivateJwk::parse(&issuer_signing_key).expect("issuer key parses"); + let issued = Arc::new(AtomicUsize::new(0)); + Mock::given(method("GET")) + .and(path("/.well-known/jwks.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"keys":[issuer_public_key]}))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "issuer":origin, + "token_endpoint":token_endpoint.as_str(), + "grant_types_supported":["client_credentials"], + "token_endpoint_auth_methods_supported":["private_key_jwt"], + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ClientCredentialsResponder { + origin: origin.clone(), + token_endpoint: token_endpoint.to_string(), + client_keys: BTreeMap::from([ + (CLIENT_KEY_ID.to_owned(), client_key.public()), + (ES256_CLIENT_KEY_ID.to_owned(), es256_client_key.public()), + ]), + signing_key, + issued: Arc::clone(&issued), + }) + .mount(&server) + .await; + TokenIssuer { + origin, + token_endpoint, + client_key, + es256_client_key, + issued, + _server: server, + } +} - let config_path = root.join("mint.yaml"); - fs::write( - &config_path, - format!( - r#"version: 1 -validationMode: supervised-local-development -issuer: {origin} -listener: {{address: 127.0.0.1, port: {port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{issuer_key_id}.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: - root: {secret_root} -audit: - path: audit/decisions.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hash-key - hashKeyVersion: 1 -accessTokens: - audiences: [{TOKEN_AUDIENCE}] - lifetimeSeconds: {ISSUED_TOKEN_LIFETIME_SECONDS} - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -clientAssertion: - audience: {token_endpoint} - maximumLifetimeSeconds: 300 - algorithms: [EdDSA, ES256] -clients: - directory: clients -"#, - secret_root = secret_root.display(), - ), - ) - .expect("write the issuer configuration"); +struct StockTokenIssuer { + origin: String, + token_endpoint: Url, + client_key: PrivateJwk, + label: String, + id: String, + port: u16, + state_root: PathBuf, + image: String, + docker: PathBuf, + _directory: tempfile::TempDir, +} - let config = MintConfig::load(&config_path).expect("the staged issuer configuration is valid"); - let audit_path = config.audit.path.clone(); - let service = Arc::new( - MintService::load(config) - .await - .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 { - let _ = shutdown_rx.await; - }) - .await - }); +impl StockTokenIssuer { + fn provider(&self) -> Arc { + Arc::new( + PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + self.token_endpoint.clone(), + CLIENT_ID, + self.client_key.clone(), + ) + .with_audience(self.origin.clone()) + .with_resource(TOKEN_AUDIENCE) + .with_scopes(["evidence:invoke".to_owned()]), + ) + .expect("the stock issuer provider is valid"), + ) + } + + fn jwks_uri(&self) -> String { + format!("{}/oauth2/jwks", self.origin) + } +} + +impl Drop for StockTokenIssuer { + fn drop(&mut self) { + let session = Session { + label: &self.label, + id: &self.id, + port: self.port, + state_root: &self.state_root, + image: &self.image, + }; + let _ = local::stop(&session, &self.docker); + } +} - let mut issuer = TokenIssuer { +fn installed_or_env(variable: &str, binary: &str) -> PathBuf { + if let Some(path) = std::env::var_os(variable) { + return PathBuf::from(path); + } + std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .map(|directory| directory.join(binary)) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| panic!("set {variable} for the exact stock-issuer gate")) +} + +fn start_stock_token_issuer() -> StockTokenIssuer { + let reservation = TcpListener::bind("127.0.0.1:0").expect("reserve stock issuer port"); + let port = reservation + .local_addr() + .expect("stock issuer address") + .port(); + let origin = format!("http://127.0.0.1:{port}"); + let token_endpoint = + Url::parse(&format!("{origin}/oauth2/token")).expect("stock issuer token endpoint parses"); + let directory = tempfile::tempdir().expect("private stock issuer state"); + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700)) + .expect("stock issuer state is owner-only"); + let state_root = directory.path().join("issuer"); + let label = format!("evidence-client-{port}"); + let id = format!("evidence-client-session-{port}"); + let client_key = generate_es256_key(ES256_CLIENT_KEY_ID); + let public_jwks = json!({"keys":[client_key.public()]}).to_string(); + let description = local::typed_local_description( + SessionIdentity { + label: label.clone(), + id: id.clone(), + }, + port, + state_root.clone(), + TOKEN_AUDIENCE.to_owned(), + vec![TypedLocalClient { + client_id: CLIENT_ID.to_owned(), + public_jwks, + claims: BTreeMap::from([ + ("registry_actor_kind".to_owned(), json!("service")), + ("evidence_tags".to_owned(), json!([CONFIGURED_TAG])), + ("evidence_audience".to_owned(), json!(RELYING_AUDIENCE)), + ]), + scopes: vec!["evidence:invoke".to_owned()], + allow_human_fixture: false, + }], + ) + .expect("the stock issuer description is valid"); + render::render(&description).expect("the stock issuer resources render"); + let pin = ThunderIdPin::load().expect("the maintained stock issuer pin loads"); + let docker = installed_or_env("DOCKER_BIN", "docker"); + let session = Session { + label: &label, + id: &id, + port, + state_root: &state_root, + image: &pin.image, + }; + drop(reservation); + local::start(&session, &docker, &mut || false).expect("the pinned stock issuer starts"); + StockTokenIssuer { origin, token_endpoint, client_key, - es256_client_key, - audit_path, - shutdown: Some(shutdown_tx), - server, + label, + id, + port, + state_root, + image: pin.image, + docker, _directory: directory, - }; - await_readiness( - "the token issuer", - Url::parse(&format!("{}/ready", issuer.origin)).expect("the readiness URL parses"), - &mut issuer.server, + } +} + +/// Replacement acceptance for the former Registry Mint dependency. The +/// private-key-JWT client obtains a token from pinned ThunderID 1.0.1, then +/// carries an Evidence request through the real runtime and verifier. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "exact gate: starts the pinned stock issuer and real Evidence runtime"] +async fn stock_issuer_token_carries_a_verified_evidence_request() { + let issuer = tokio::task::spawn_blocking(start_stock_token_issuer) + .await + .expect("the stock issuer startup task completes"); + let jwks_uri = issuer.jwks_uri(); + let deployment = start_trusting_with_request_burst_and_jwks( + resolved_source_answer(), + Some(&issuer.origin), + Some(&jwks_uri), + 10, ) .await; - drop(port_handoff); - issuer + let client = deployment.client_using(issuer.provider()); + let definitions = client + .discover() + .await + .expect("the stock-issued token discovers Evidence definitions"); + let prepared = client + .prepare(spec( + &definitions, + "stock-issuer", + SubjectExpectations::AcceptFirstUse, + )) + .expect("the stock-issuer request prepares"); + let accepted = client + .request_and_verify(&prepared) + .await + .expect("the stock-issued token carries a verified request"); + assert_eq!(accepted.evidence().supports_requirement, REQUIREMENT); } fn fixture_root() -> PathBuf { @@ -1654,6 +1839,7 @@ fn rewrite_for_local_profile( bundle_root: &Path, source_origin: &str, issuer_origin: &str, + issuer_jwks_uri: &str, public_origin: &str, signing_key_id: &str, ) { @@ -1684,16 +1870,22 @@ fn rewrite_for_local_profile( &format!("issuer: {issuer_origin}"), 1, ); + replace_exact( + &mut document, + "audiences: [evidence-fixture]", + &format!("audiences: [{TOKEN_AUDIENCE}]"), + 1, + ); replace_exact( &mut document, "jwksUri: https://identity.invalid/.well-known/jwks.json", - &format!("jwksUri: {issuer_origin}/.well-known/jwks.json"), + &format!("jwksUri: {issuer_jwks_uri}"), 1, ); replace_exact( &mut document, "algorithms: [ES256]", - "algorithms: [EdDSA, ES256]", + "algorithms: [EdDSA, ES256, RS256]", 1, ); replace_exact( From 67e66a0ba6b7fb20cbaac1e46127790282a01632 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 12 Sep 2026 23:58:04 +0700 Subject: [PATCH 013/120] test(breg): close contextual authorization gates Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/api/actions.rs | 13 +++++-- .../src/api/tests/immediate_action_tests.rs | 16 ++++----- crates/registry-breg/src/task_grant/tests.rs | 12 +++---- crates/registry-breg/tests/http_auth.rs | 36 ++++++++++--------- 4 files changed, 43 insertions(+), 34 deletions(-) diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 9f06a9fe56..5d6d3c75c9 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -151,9 +151,16 @@ fn authorize_action<'a>( let actual = claims.actor_kind()?; if !matches!( (expected, actual), - (crate::contract::ActorKindSource::Human, registry_platform_oidc::ActorKind::Human) - | (crate::contract::ActorKindSource::Agent, registry_platform_oidc::ActorKind::Agent) - | (crate::contract::ActorKindSource::Service, registry_platform_oidc::ActorKind::Service) + ( + crate::contract::ActorKindSource::Human, + registry_platform_oidc::ActorKind::Human + ) | ( + crate::contract::ActorKindSource::Agent, + registry_platform_oidc::ActorKind::Agent + ) | ( + crate::contract::ActorKindSource::Service, + registry_platform_oidc::ActorKind::Service + ) ) { return None; } diff --git a/crates/registry-breg/src/api/tests/immediate_action_tests.rs b/crates/registry-breg/src/api/tests/immediate_action_tests.rs index a040125348..0163f94077 100644 --- a/crates/registry-breg/src/api/tests/immediate_action_tests.rs +++ b/crates/registry-breg/src/api/tests/immediate_action_tests.rs @@ -1,20 +1,20 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::auth::{AuthorityClaimConfig, RegistryAuthenticator}; use crate::compiler::{compile_project, CompileProfile}; use crate::contract::parse_project_yaml; use crate::cursor::CursorCodec; -use crate::auth::{AuthorityClaimConfig, RegistryAuthenticator}; use crate::model::{CompiledAction, CompiledRegistry}; use crate::postgres::{ ConnectionConfig, ExpectedRegistryIdentity, PoolBounds, PostgresRecordMutationService, RegistryLockKey, }; -use std::time::{Duration, Instant}; -use zeroize::Zeroizing; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; use registry_platform_testing::{oidc_verifier_config, MockIdp}; +use std::time::{Duration, Instant}; +use zeroize::Zeroizing; const PROJECT: &str = r#" apiVersion: registry.registrystack.org/v1alpha1 @@ -85,10 +85,7 @@ async fn signed_task_token_cannot_discover_or_invoke_an_ordinary_immediate_actio JwksFetcherConfig::defaults(), FetchUrlPolicy::dev(), )), - AuthorityClaimConfig::new( - "registry_principal", - Some("registry_purpose".to_owned()), - ), + AuthorityClaimConfig::new("registry_principal", Some("registry_purpose".to_owned())), ) .expect("action verifier config is valid"); let now = std::time::SystemTime::now() @@ -112,7 +109,10 @@ async fn signed_task_token_cannot_discover_or_invoke_an_ordinary_immediate_actio "identity": {"tenant": "tenant-a"}, "scope": "case.rename" })); - let claims = auth.authenticate(&token).await.expect("signed task token verifies"); + let claims = auth + .authenticate(&token) + .await + .expect("signed task token verifies"); let service = service_for(registry.clone(), true); let route = ®istry.actions().routes[0]; assert!(authorize_action(&service, route, &claims, &QueryOptions::default()).is_none()); diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs index f39c7ac4d0..9608e223db 100644 --- a/crates/registry-breg/src/task_grant/tests.rs +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -31,7 +31,7 @@ async fn status(State(state): State, headers: HeaderMap) -> Json TaskGrantBinding { - serde_json::from_value(serde_json::json!({"grantId":Uuid::new_v4().to_string(),"authority":"casework","sourceIssuer":"https://casework.test","principal":"agent","client":"agent-client","resource":"urn:breg:test","purpose":"review","bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get","patch"]}]},"subjects":{"person_reference":"synthetic-person","active":true},"expiresAt":chrono::Utc::now().timestamp()+900})).unwrap() + serde_json::from_value(serde_json::json!({"grantId":Uuid::new_v4().to_string(),"authority":"casework","sourceIssuer":"https://casework.test","principal":"agent","client":"agent-client","resource":"urn:breg:test","purpose":"review","bounds":{"type":"breg","permissions":[{"collection":"records","operations":["get","patch"]}]},"subjects":{"subject_reference":"synthetic-subject","active":true},"expiresAt":chrono::Utc::now().timestamp()+900})).unwrap() } #[tokio::test] @@ -93,9 +93,9 @@ async fn each_mutating_attempt_reads_fresh_status_and_compares_every_immutable_b changed[field] = match field { "grantId" => serde_json::json!(Uuid::new_v4().to_string()), "bounds" => { - serde_json::json!({"type":"breg","permissions":[{"collection":"people","operations":["get"]}]}) + serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get"]}]}) } - "subjects" => serde_json::json!({"person_reference":"other-person","active":true}), + "subjects" => serde_json::json!({"subject_reference":"other-subject","active":true}), "expiresAt" => serde_json::json!(binding.expires_at + 1), _ => serde_json::json!("different"), }; @@ -132,11 +132,11 @@ fn binding_debug_and_scalar_validation_preserve_privacy_and_exact_subjects() { assert!(grant.validate().is_ok()); for value in [ Value::Null, - serde_json::json!(["person"]), - serde_json::json!({"id":"person"}), + serde_json::json!(["subject"]), + serde_json::json!({"id":"subject"}), serde_json::json!(1.5), ] { - grant.subjects.insert("person_reference".into(), value); + grant.subjects.insert("subject_reference".into(), value); assert_eq!(grant.validate(), Err(TaskGrantError::Refused)); } } diff --git a/crates/registry-breg/tests/http_auth.rs b/crates/registry-breg/tests/http_auth.rs index 35bd07b258..65b3e7a0cf 100644 --- a/crates/registry-breg/tests/http_auth.rs +++ b/crates/registry-breg/tests/http_auth.rs @@ -63,6 +63,7 @@ entities: - {id: label, type: string, required: true, maxLength: 100, classification: public} - {id: secret, type: string, required: true, maxLength: 100, classification: restricted} - {id: jurisdiction, type: string, required: true, maxLength: 100, classification: internal} + - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} accessProfiles: - id: public default: true @@ -379,21 +380,22 @@ async fn task_grant_is_exactly_bound_and_cannot_fall_back_to_standing_authority( ) .await; assert_eq!(delegated.status(), StatusCode::OK); - let requests = harness.records.requests.lock().unwrap(); - let context = &requests[0].context; - let task_grant = context.task_grant().expect("task grant is retained"); - assert_eq!( - task_grant.subjects(), - &BTreeMap::from([( - "tenant_claim".to_owned(), - json!("tenant-from-approved-identity"), - )]) - ); - assert_eq!( - context.row_boundaries()[0].values(), - &BTreeSet::from(["tenant-from-approved-identity".to_owned()]) - ); - drop(requests); + { + let requests = harness.records.requests.lock().unwrap(); + let context = &requests[0].context; + let task_grant = context.task_grant().expect("task grant is retained"); + assert_eq!( + task_grant.subjects(), + &BTreeMap::from([( + "tenant_claim".to_owned(), + json!("tenant-from-approved-identity"), + )]) + ); + assert_eq!( + context.row_boundaries()[0].values(), + &BTreeSet::from(["tenant-from-approved-identity".to_owned()]) + ); + } let standing = harness .send( @@ -452,8 +454,8 @@ async fn standing_citizen_agent_requires_the_registered_client_actor_pair() { #[test] fn task_profiles_allow_governed_draft_authoring_and_refuse_direct_target_mutation() { let direct = CONTEXTUAL_PROJECT.replace( - "operations: [get], readableFields: [label]", - "operations: [create, get], readableFields: [label], writableFields: [label]", + "operations: [get]\n readableFields: [label]\n rowBoundaries:", + "operations: [create, get]\n readableFields: [label]\n writableFields: [label]\n rowBoundaries:", ); let project = parse_project_yaml(direct.as_bytes()).expect("direct mutation project parses"); let failure = compile_project(&project, &[], CompileProfile::Authoring) From bfa0a8e8532a3239b611f55651f64545a3ced719 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:02:29 +0700 Subject: [PATCH 014/120] feat(casework): bind governed OAuth scopes to task approval Signed-off-by: Jeremi Joslin --- .../registry-casework-core/src/task_grant.rs | 50 +++++++++++++++++++ crates/registry-casework/src/task_grants.rs | 8 ++- .../src/task_grants/http_tests.rs | 4 +- .../src/task_grants/tests.rs | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index 6ac3b5b817..d7e9219596 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -28,6 +28,7 @@ pub struct TaskTemplate { pub agent: IssuerPrincipal, pub client: String, pub resource: String, + pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, /// Exact token identity keys mapped to governed source logical fields. @@ -132,6 +133,13 @@ impl TaskTemplate { || !bounded(&self.agent.subject, 512) || !bounded(&self.client, 512) || !bounded(&self.resource, 512) + || !unique(&self.scopes, 32) + || self.scopes.iter().any(|scope| { + scope.len() > 128 + || !scope + .bytes() + .all(|byte| matches!(byte, 0x21 | 0x23..=0x5b | 0x5d..=0x7e)) + }) || !bounded(&self.purpose, 128) || self.purpose.bytes().any(|byte| { !(byte.is_ascii_lowercase() @@ -256,6 +264,46 @@ fn unique(values: &[String], maximum: usize) -> bool { #[cfg(test)] mod tests { use super::*; + #[test] + fn governed_scopes_require_explicit_bounded_oauth_names() { + let project: CaseworkProject = serde_json::from_value(serde_json::json!({ + "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, + "casework": {"id":"tasks", "version":"1"}, + "accessProfiles":[{"id":"staff", "principalClaim":"sub", "requiredScopes":["casework:staff"], "role":"staff"}], + "queues":[{"id":"review", "label":"Review"}], + "sources":[{"id":"source", "adapter":"test", "description":"Test source", "requests":[{"entity":"request", "queue":"review"}]}] + })).unwrap(); + let mut template: TaskTemplate = serde_json::from_value(serde_json::json!({ + "id":"summary", "version":"1", "label":"Prepare summary", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"agent-client", "resource":"urn:test:breg", "scopes":["records:get", "records:draft"], + "purpose":"prepare-summary", "bounds":{"type":"breg", "permissions":[{"collection":"records", "operations":["get"]}]}, + "subjects":{"subject_reference":"subject-reference"}, "lifetimeSeconds":900 + })).unwrap(); + assert!(template.check(&project).is_ok()); + for scopes in [ + vec![], + vec![String::new()], + vec!["*".into()], + vec!["records:*".into()], + vec!["records:get".into(), "records:get".into()], + vec!["two scopes".into()], + vec!["quote\"".into()], + vec!["back\\slash".into()], + vec!["non-ascii-é".into()], + vec!["a".repeat(129)], + (0..33).map(|n| format!("scope:{n}")).collect(), + ] { + template.scopes = scopes; + assert!( + template.check(&project).is_err(), + "invalid governed OAuth scopes accepted" + ); + } + } + #[test] fn proposal_identity_ignores_only_mutable_source_revision() { let binding = SourceBinding { @@ -296,6 +344,7 @@ pub struct TaskTemplatePreview { pub agent: IssuerPrincipal, pub client: String, pub resource: String, + pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, pub subjects: BTreeMap, @@ -320,6 +369,7 @@ pub struct TaskGrantView { pub agent: IssuerPrincipal, pub client: String, pub resource: String, + pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, pub expires_at: u64, diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 2bd39b2dfa..dadebb5c03 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -369,7 +369,7 @@ impl TaskAuthority { "registry_grant_id":grant.id,"registry_grant_authority":grant.authority,"registry_grant_source_issuer":grant.source_issuer, "registry_grant_client":grant.template.client,"registry_grant_resource":grant.template.resource, "registry_purpose":grant.template.purpose,"registry_grant_exp":grant.expires_at, - "registry_grant_bounds":grant.template.bounds,"identity":grant.subjects}); + "registry_grant_bounds":grant.template.bounds,"scope":grant.template.scopes.join(" "),"identity":grant.subjects}); let header = json!({"alg":self.key.alg,"kid":self.key.kid,"typ":"JWT"}); let input = format!( "{}.{}", @@ -456,6 +456,7 @@ impl crate::CaseworkService { agent: template.agent.clone(), client: template.client.clone(), resource: template.resource.clone(), + scopes: template.scopes.clone(), purpose: template.purpose.clone(), bounds: template.bounds.clone(), subjects, @@ -468,6 +469,7 @@ impl crate::CaseworkService { }) } + #[allow(clippy::too_many_arguments)] pub(crate) async fn approve_task( &self, actor: &ActorContext, @@ -707,6 +709,7 @@ fn grant_view(stored: &StoredTaskGrant) -> TaskGrantView { agent: stored.grant.template.agent.clone(), client: stored.grant.template.client.clone(), resource: stored.grant.template.resource.clone(), + scopes: stored.grant.template.scopes.clone(), purpose: stored.grant.template.purpose.clone(), bounds: stored.grant.template.bounds.clone(), expires_at: stored.grant.expires_at, @@ -719,3 +722,6 @@ mod tests; #[cfg(all(test, feature = "postgres-test"))] mod http_tests; + +#[cfg(all(test, feature = "postgres-test"))] +mod native_exchange_tests; diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index 31eded522c..c9c5bd51f5 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -158,7 +158,7 @@ async fn fixture(lifetime: u64) -> Fixture { let store = PostgresStore::connect_migration(&config, &secrets).unwrap(); store.migrate().await.unwrap(); std::env::remove_var(name); - let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); + let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); store .activate_task_templates(&project.task_templates) @@ -423,6 +423,8 @@ async fn task_http_approval_assertion_status_and_revocation_enforce_current_auth ) .unwrap(); assert_eq!(payload["registry_grant_exp"], approved["expiresAt"]); + assert_eq!(payload["scope"], "records:get"); + assert_eq!(approved["scopes"], json!(["records:get"])); assert!(payload["exp"].as_u64().unwrap() - payload["iat"].as_u64().unwrap() <= 60); assert_eq!(payload["identity"]["person_reference"], "synthetic-person"); assert_eq!(payload["sub"], "agent"); diff --git a/crates/registry-casework/src/task_grants/tests.rs b/crates/registry-casework/src/task_grants/tests.rs index 2046dab4a4..81bded5ef2 100644 --- a/crates/registry-casework/src/task_grants/tests.rs +++ b/crates/registry-casework/src/task_grants/tests.rs @@ -32,7 +32,7 @@ async fn template_versions_are_immutable_and_retirement_cannot_revive_existing_g "id":"summary", "version":"1", "label":"Prepare summary", "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", "itemKinds":["request"], "itemStates":["claimed"], "agent":{"issuer":"https://issuer.test","subject":"agent"}, "client":"agent-client", "resource":"urn:test:breg", - "purpose":"prepare-summary", "bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]}, + "purpose":"prepare-summary","scopes":["records:get"], "bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]}, "subjects":{"person_reference":"person-reference"}, "lifetimeSeconds":900 })).unwrap(); store From ad51f8cb830ae7ed79aad539081a697fb3ade6a9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:02:29 +0700 Subject: [PATCH 015/120] feat(clients): expose approved task OAuth scopes Signed-off-by: Jeremi Joslin --- .../__test__/task-grants.test.js | 4 ++-- .../registry-casework-client-node/client.d.ts | 4 ++-- .../registry_casework_client/__init__.pyi | 2 ++ .../tests/python/test_task_grants.py | 4 ++-- .../registry-stack-client-node/breg/client.d.ts | 9 +++++++++ .../registry-stack-client-node/breg/client.js | 17 +++++++++++++++++ .../registry-stack-client-node/breg/index.d.ts | 8 ++++++++ .../casework/client.d.ts | 4 ++-- 8 files changed, 44 insertions(+), 8 deletions(-) diff --git a/crates/registry-casework-client-node/__test__/task-grants.test.js b/crates/registry-casework-client-node/__test__/task-grants.test.js index c2c652951a..d80e43cdf2 100644 --- a/crates/registry-casework-client-node/__test__/task-grants.test.js +++ b/crates/registry-casework-client-node/__test__/task-grants.test.js @@ -5,8 +5,8 @@ const http=require('node:http'); const {CaseworkClient}=require('../client'); const item='00000000-0000-4000-8000-000000000001'; const grant='00000000-0000-4000-8000-000000000002'; -const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; -const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; +const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',scopes:['evidence:invoke'],purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; +const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,scopes:preview.scopes,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; test('task grants preserve preview, approval revision/key and token-only machine calls',async(t)=>{ const requests=[]; const server=http.createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{requests.push({path:req.url,method:req.method,headers:req.headers,body});let value; diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 60e7999040..783f1586ae 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -25,13 +25,13 @@ export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type export interface TaskPermission { collection: string; operations: ReadonlyArray } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { - id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { - id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean } export interface TaskGrantList { grants: ReadonlyArray } diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index aef0da0401..47c6c88c8b 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -42,6 +42,7 @@ class TaskTemplatePreview(TypedDict): agent: IssuerPrincipal client: str resource: str + scopes: list[str] purpose: str bounds: TaskGrantBounds subjects: dict[str, str | int | bool] @@ -56,6 +57,7 @@ class TaskGrantView(TypedDict): agent: IssuerPrincipal client: str resource: str + scopes: list[str] purpose: str bounds: TaskGrantBounds expiresAt: int diff --git a/crates/registry-casework-client-py/tests/python/test_task_grants.py b/crates/registry-casework-client-py/tests/python/test_task_grants.py index aef4e1e64d..755e465034 100644 --- a/crates/registry-casework-client-py/tests/python/test_task_grants.py +++ b/crates/registry-casework-client-py/tests/python/test_task_grants.py @@ -11,8 +11,8 @@ ITEM = "00000000-0000-4000-8000-000000000001" GRANT = "00000000-0000-4000-8000-000000000002" -PREVIEW = {"id": "verify-status", "version": "1", "label": "Verify status", "agent": {"issuer": "https://issuer.example", "subject": "agent-one"}, "client": "agent-client", "resource": "urn:evidence", "purpose": "verify-status", "bounds": {"type": "evidence", "requirement": "status"}, "subjects": {"person_reference": "synthetic-reference"}, "lifetimeSeconds": 900} -VIEW = {"id": GRANT, "templateId": PREVIEW["id"], "templateVersion": "1", **{k: PREVIEW[k] for k in ("agent", "client", "resource", "purpose", "bounds")}, "expiresAt": 2000000900, "invalidated": False} +PREVIEW = {"id": "verify-status", "version": "1", "label": "Verify status", "agent": {"issuer": "https://issuer.example", "subject": "agent-one"}, "client": "agent-client", "resource": "urn:evidence", "scopes": ["evidence:invoke"], "purpose": "verify-status", "bounds": {"type": "evidence", "requirement": "status"}, "subjects": {"person_reference": "synthetic-reference"}, "lifetimeSeconds": 900} +VIEW = {"id": GRANT, "templateId": PREVIEW["id"], "templateVersion": "1", **{k: PREVIEW[k] for k in ("agent", "client", "resource", "scopes", "purpose", "bounds")}, "expiresAt": 2000000900, "invalidated": False} class TaskGrantTests(unittest.TestCase): def test_bounded_human_approval_and_token_only_machine_requests(self): diff --git a/crates/registry-stack-client-node/breg/client.d.ts b/crates/registry-stack-client-node/breg/client.d.ts index a17841ccf8..d03b6dd877 100644 --- a/crates/registry-stack-client-node/breg/client.d.ts +++ b/crates/registry-stack-client-node/breg/client.d.ts @@ -78,6 +78,15 @@ export interface PrivateKeyJwtConfig { trustedRootCertificates?: string | null } +/** OAuth credentials are returned to the caller. Keep them out of logs and persistence. */ +export class PrivateKeyJwt { + constructor(config: PrivateKeyJwtConfig) + /** Fresh RFC 8693 exchange. Requires resource and scopes; never shares the service cache. */ + exchange(subjectToken: string): Promise + /** Client credentials with the shared provider's bounded service-token cache. */ + bearerToken(): Promise +} + export type BaseRegistryAuthorization = | { static: string } | { privateKeyJwt: PrivateKeyJwtConfig } diff --git a/crates/registry-stack-client-node/breg/client.js b/crates/registry-stack-client-node/breg/client.js index cbf80f2cea..9f3600288b 100644 --- a/crates/registry-stack-client-node/breg/client.js +++ b/crates/registry-stack-client-node/breg/client.js @@ -346,8 +346,25 @@ class BaseRegistryClient extends native.BaseRegistryClient { } } +class PrivateKeyJwt { + #native; + constructor(config) { + try { this.#native = new native.PrivateKeyJwt(cloneJson(config, { nodes: 0, stringBytes: 0, active: new WeakSet() }, 0, 'configuration')); } + catch (error) { throw normalize(error, 'configuration'); } + } + async exchange(subjectToken) { + try { return await this.#native.exchange(subjectToken); } + catch (error) { throw normalize(error, 'token'); } + } + async bearerToken() { + try { return await this.#native.bearerToken(); } + catch (error) { throw normalize(error, 'token'); } + } +} + module.exports = { verifyWebhookDelivery, + PrivateKeyJwt, BaseRegistryClient, BaseRegistryClientError, BRegMetadata: native.BRegMetadata, diff --git a/crates/registry-stack-client-node/breg/index.d.ts b/crates/registry-stack-client-node/breg/index.d.ts index 9af3be9994..fa7f2e6c63 100644 --- a/crates/registry-stack-client-node/breg/index.d.ts +++ b/crates/registry-stack-client-node/breg/index.d.ts @@ -244,6 +244,14 @@ export declare class BRegTombstoneBinding { } export type TombstoneBinding = BRegTombstoneBinding +/** Shared OAuth client authentication and uncached task-token exchange. */ +export declare class PrivateKeyJwt { + constructor(config: any) + exchange(subjectToken: string): Promise + bearerToken(): Promise +} +export type PrivateKeyJwtBinding = PrivateKeyJwt + export interface CompleteOutcome { kind: string value: any diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 60e7999040..783f1586ae 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -25,13 +25,13 @@ export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type export interface TaskPermission { collection: string; operations: ReadonlyArray } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { - id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { - id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; purpose: string + id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean } export interface TaskGrantList { grants: ReadonlyArray } From c6e401cff0ec507ec9452d1cdcd6d8b852df88a2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:02:30 +0700 Subject: [PATCH 016/120] docs(casework): specify scopes and token-only machine endpoints Signed-off-by: Jeremi Joslin --- products/casework/TASK_GRANTS.md | 6 +- .../generated/registry-casework.openapi.json | 63 ++++++++++++------- products/casework/scripts/generate_openapi.py | 4 +- .../casework/scripts/test_generate_openapi.py | 13 +++- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index a4b1003b62..3dccdecee9 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -14,13 +14,15 @@ Add `taskTemplates` to the Casework project. Each template declares: - `eligibleTeams`, human Staff/Supervisor `eligibleProfiles`, `source`, `itemKinds`, and eligible `itemStates`. - Exact `agent: {issuer, subject}`, OAuth `client`, one absolute `resource`, - and `purpose`. + and `purpose`, plus explicit OAuth `scopes` for that resource. - `bounds`, either `{type: evidence, requirement: ...}` or `{type: breg, permissions: [{collection: ..., operations: [...]}]}`. - `subjects`, mapping token identity keys to governed source logical fields. - `lifetimeSeconds`, no more than 900 seconds. -Use exact product operation names. Wildcards and duplicate bounds are refused. +Scopes are immutable approved authorization, not inferred from product operations. +The assertion includes those scopes, and stock token exchange only accepts a +requested subset. Use exact product operation names. Wildcards and duplicate bounds are refused. Required source fields must be disclosed to the approving human and exposed to the configured service reader for later checks. Callers cannot supply subject values. Changing a template requires a new version. Retiring a version diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index 4dc550f846..c7d5e3f877 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -4213,6 +4213,18 @@ "minLength": 1, "type": "string" }, + "scopes": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21\\x23-\\x29\\x2b-\\x5b\\x5d-\\x7e]+$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "templateId": { "maxLength": 512, "minLength": 1, @@ -4231,6 +4243,7 @@ "agent", "client", "resource", + "scopes", "purpose", "bounds", "expiresAt", @@ -4337,6 +4350,18 @@ "minLength": 1, "type": "string" }, + "scopes": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21\\x23-\\x29\\x2b-\\x5b\\x5d-\\x7e]+$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "source": { "maxLength": 512, "minLength": 1, @@ -4365,6 +4390,7 @@ "agent", "client", "resource", + "scopes", "purpose", "bounds", "subjects", @@ -4416,6 +4442,18 @@ "minLength": 1, "type": "string" }, + "scopes": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[\\x21\\x23-\\x29\\x2b-\\x5b\\x5d-\\x7e]+$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, "subjects": { "additionalProperties": { "type": [ @@ -4440,6 +4478,7 @@ "agent", "client", "resource", + "scopes", "purpose", "bounds", "subjects", @@ -11425,18 +11464,6 @@ "type": "string" } }, - { - "description": "Explicit Casework access profile. It never selects BReg authority.", - "in": "header", - "name": "Registry-Casework-Profile", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, { "description": "Immutable task grant identifier.", "in": "path", @@ -11707,18 +11734,6 @@ "type": "string" } }, - { - "description": "Explicit Casework access profile. It never selects BReg authority.", - "in": "header", - "name": "Registry-Casework-Profile", - "required": true, - "schema": { - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9_.:-]+$", - "type": "string" - } - }, { "description": "Immutable task grant identifier.", "in": "path", diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index 4ef83264be..6dc61d4664 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -1187,7 +1187,7 @@ def task_schemas() -> dict: uuid = {"type":"string", "format":"uuid"} subjects = {"type":"object", "maxProperties":32, "additionalProperties":{"type":["string","integer","boolean"]}} permission = obj({"collection":text, "operations":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":text}}, ["collection","operations"]) - common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "purpose":text, "bounds":ref("TaskGrantBounds")} + common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "scopes":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[\x21\x23-\x29\x2b-\x5b\x5d-\x7e]+$"}}, "purpose":text, "bounds":ref("TaskGrantBounds")} preview = {"id":text, "version":text, "label":text, **common, "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} template = {**preview, "eligibleTeams":array(text), "eligibleProfiles":array(text), "source":text, "itemKinds":array(text), "itemStates":{"type":"array","items":{"enum":["claimed","waiting_applicant","waiting_application"]}}} template["subjects"] = {"type":"object", "minProperties":1, "maxProperties":32, "additionalProperties":text} @@ -1561,7 +1561,7 @@ def document(contract: dict) -> dict: }) for path in ["/v1/task-grants/{grant_id}/assertion", "/v1/task-grants/{grant_id}/status"]: for operation_value in paths[path].values(): - operation_value["parameters"] = [p for p in operation_value["parameters"] if p.get("name") != "registry-casework-profile"] + operation_value["parameters"] = [p for p in operation_value["parameters"] if p.get("name", "").lower() != "registry-casework-profile"] apply_operation_contract(paths, contract, catalog) apply_hosted_validation_headers(paths) result = { diff --git a/products/casework/scripts/test_generate_openapi.py b/products/casework/scripts/test_generate_openapi.py index db37dd0dbd..d47b0fe1fd 100644 --- a/products/casework/scripts/test_generate_openapi.py +++ b/products/casework/scripts/test_generate_openapi.py @@ -131,7 +131,7 @@ def test_checkpoint_specific_schema_bounds_are_explicit(self) -> None: "caseload_moved", "clock_reminder", "clock_step_applied", "clock_recomputed", "released", "draft_saved", "attempt_reserved", "attempt_uncertain", "action_completed", "attempt_settled", "superseded", - "completed", + "completed", "task_approved", "task_revoked", "task_invalidated", }, set(schemas["HistoryEntry"]["properties"]["kind"]["enum"]), ) @@ -908,7 +908,16 @@ def test_maintained_headers_have_the_exact_bounded_wire_contract(self) -> None: path = rust["path"] operation = self.openapi["paths"][path][method] names = {parameter["name"] for parameter in operation["parameters"]} - if path not in {"/health", "/ready", "/events/sources/{source_id}"}: + without_profiles = { + "/health", "/ready", "/events/sources/{source_id}", + "/.well-known/jwks.json", + "/v1/task-grants/{grant_id}/assertion", + "/v1/task-grants/{grant_id}/status", + } + if path in without_profiles: + self.assertNotIn("Registry-Casework-Profile", names) + self.assertNotIn("Registry-Source-Profile", names) + else: self.assertEqual( profile_schema, parameter_schema( From 1106793316b80463b70bd99270050df72e4d680f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:03:35 +0700 Subject: [PATCH 017/120] test(relay): retain stock issuer gate in HTTP journeys Signed-off-by: Jeremi Joslin --- products/relay-v2/README.md | 6 ++++++ products/relay-v2/scripts/test-http.sh | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md index 744cd2e2b4..87761826c1 100644 --- a/products/relay-v2/README.md +++ b/products/relay-v2/README.md @@ -60,6 +60,12 @@ Set `RELAY_V2_SDMX_CONFORMANCE=1` when running `scripts/test-http.sh` to fetch the digest-locked schemas temporarily and validate generated data and structure responses. +Set `RELAY_V2_STOCK_ISSUER=1` to include the pinned ThunderID container journey +for private-key-JWT service issuance and protected lookup. Docker must be +available; the fixture uses an isolated loopback port and owned temporary state. +The existing CI HTTP journey enables this replacement for the former Mint-backed +issuer test. Ordinary local HTTP tests do not require Docker. + `scripts/check-client-contract.sh` verifies the standalone fixed route and problem inventory used by `registry-relay-client`; it has no live deployment or fixture dependency. diff --git a/products/relay-v2/scripts/test-http.sh b/products/relay-v2/scripts/test-http.sh index cfa4d003b0..0e49a87b16 100755 --- a/products/relay-v2/scripts/test-http.sh +++ b/products/relay-v2/scripts/test-http.sh @@ -10,6 +10,15 @@ CARGO_PROFILE_DEV_DEBUG=0 \ CARGO_PROFILE_TEST_DEBUG=0 \ cargo test --locked -p registry-relay-v2 --features tooling --test acceptance_http +if [[ "${RELAY_V2_STOCK_ISSUER:-0}" == "1" ]]; then + CARGO_INCREMENTAL=0 \ + CARGO_PROFILE_DEV_DEBUG=0 \ + CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked -p registry-relay-v2 --features tooling --test acceptance_http \ + stock_issuer_registered_authority_drives_a_protected_relay_lookup \ + -- --exact --ignored +fi + CARGO_INCREMENTAL=0 \ CARGO_PROFILE_DEV_DEBUG=0 \ CARGO_PROFILE_TEST_DEBUG=0 \ From a7bab058c4b0a4167450b280a11008b33f5981d2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:10:27 +0700 Subject: [PATCH 018/120] fix(casework): render permissions in source reader configuration Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/source_add.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 267802feae..44aba68c8c 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -499,7 +499,7 @@ fn candidate_fragments(entity_id: &str, projection: &[String]) -> (Value, Value) json!({ "id":READER_CLIENT_ID, "default":false, "principalClaim":READER_PRINCIPAL_CLAIM, "requiredScopes":[READER_SCOPE], "requiredPurposes":[READER_PURPOSE], - "grants":[{"entity":entity_id,"operations":["get","list"],"readableFields":fields,"readableRequestFields":["review_state"],"rowBoundaries":[]}] + "permissions":[{"entity":entity_id,"operations":["get","list"],"readableFields":fields,"readableRequestFields":["review_state"],"rowBoundaries":[]}] }), ) } @@ -601,7 +601,7 @@ fn insert_access_profile(text: &str, entity_id: &str, projection: &[String]) -> leading_spaces(line) == 0 && line.trim_start().starts_with("accessProfiles:") }) .context("narrow YAML patch could not locate accessProfiles")?; - let block = format!(" - id: {READER_CLIENT_ID}\n default: false\n principalClaim: {READER_PRINCIPAL_CLAIM}\n requiredScopes: [{READER_SCOPE}]\n requiredPurposes: [{READER_PURPOSE}]\n grants:\n - entity: {entity_id}\n operations: [get, list]\n readableFields: {fields}\n readableRequestFields: [review_state]\n rowBoundaries: []\n"); + let block = format!(" - id: {READER_CLIENT_ID}\n default: false\n principalClaim: {READER_PRINCIPAL_CLAIM}\n requiredScopes: [{READER_SCOPE}]\n requiredPurposes: [{READER_PURPOSE}]\n permissions:\n - entity: {entity_id}\n operations: [get, list]\n readableFields: {fields}\n readableRequestFields: [review_state]\n rowBoundaries: []\n"); let line = lines[start]; let logical = line.trim_end_matches(['\r', '\n']); let value = logical @@ -1369,11 +1369,11 @@ mod tests { "request_lifecycle" ); assert_eq!( - root["accessProfiles"][0]["grants"][0]["operations"], + root["accessProfiles"][0]["permissions"][0]["operations"], json!(["get", "list"]) ); assert_eq!( - root["accessProfiles"][0]["grants"][0]["readableRequestFields"], + root["accessProfiles"][0]["permissions"][0]["readableRequestFields"], json!(["review_state"]) ); apply_breg_candidate(&mut root, "request", &[]).unwrap(); @@ -1410,7 +1410,7 @@ mod tests { assert!(rendered.contains("# keep authored context")); assert!(rendered.contains("accessProfiles: # keep the profile context")); assert_eq!( - expected["accessProfiles"][0]["grants"][0]["readableFields"], + expected["accessProfiles"][0]["permissions"][0]["readableFields"], json!(["record", "region"]) ); assert_eq!( @@ -1426,13 +1426,13 @@ mod tests { #[test] fn candidate_refuses_conflicting_existing_grant() { - let mut root = json!({"entities":[{"id":"request"}],"accessProfiles":[{"id":"casework-reader","grants":[]}]}); + let mut root = json!({"entities":[{"id":"request"}],"accessProfiles":[{"id":"casework-reader","permissions":[]}]}); assert!(apply_breg_candidate(&mut root, "request", &[]).is_err()); } #[test] fn narrow_yaml_patch_preserves_comments() { - let input = "# useful\nentities:\n - id: request\n route: requests\naccessProfiles:\n - id: reader\n # keep this\n grants: []\n"; + let input = "# useful\nentities:\n - id: request\n route: requests\naccessProfiles:\n - id: reader\n # keep this\n permissions: []\n"; let mut expected: Value = serde_norway::from_str(input).unwrap(); apply_breg_candidate(&mut expected, "request", &[]).unwrap(); let patched = @@ -1841,7 +1841,7 @@ mod tests { "accessProfiles": [{ "id":"reviewer", "principalClaim":"registry_principal", - "grants":[{ + "permissions":[{ "entity":"request", "rowBoundaries":[{"field":"region","claim":"allowed_regions","operator":"in"}] }] From 9707d9816a7d3059e513bdecc023e44c8ddf2a0d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:10:27 +0700 Subject: [PATCH 019/120] docs(auth): remove retired issuer guidance from client tooling Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/dev/mod.rs | 2 ++ crates/registry-caseworkctl/src/dev/tests.rs | 5 ++--- crates/registry-caseworkctl/src/project.rs | 4 ++-- .../src/client/private_key_jwt.rs | 8 ++------ 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 1d45171c10..7913ab5a58 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -117,8 +117,10 @@ enum DevAction { #[derive(Debug, Args)] struct TokenArgs { + /// Registered local teaching client identifier from the retained dev state. #[arg(value_name = "CLIENT")] client: String, + /// Existing authored Casework project directory. #[arg(value_name = "PROJECT", default_value = ".")] project: PathBuf, } diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index 9d7cf45fb2..e099e8ab0c 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -212,8 +212,7 @@ fn clients_file_refuses_invalid_and_reserved_claim_names() { assert!(refusal.contains("claim names"), "{refusal}"); assert!(refusal.contains("RFC 6749 scope-tokens"), "{refusal}"); - // `aud` was previously accepted here, then rejected when `dev` copied it - // into Mint's closed client-registration contract. + // Registered claims belong to the issuer, not authored client claims. let reserved = STANDALONE_DEV_CLIENTS.replace("registry_actor_kind: human", "aud: human"); assert_ne!(reserved, STANDALONE_DEV_CLIENTS); let refusal = format!("{:#}", config::clients(reserved.as_bytes()).unwrap_err()); @@ -499,7 +498,7 @@ fn generated_operator_config_loads_through_the_runtime_contract() { assert_eq!(config.kind, registry_casework::RUNTIME_CONFIG_KIND); assert_eq!(config.package.root, project); assert_eq!(config.listener.bind, "127.0.0.1:8092".parse().unwrap()); - // Mint emits one space-delimited `scope` claim, not the deployment default. + // The local issuer emits one space-delimited `scope` claim. assert_eq!(config.authentication.oidc.scope_claim, "scope"); assert!(matches!( config.authentication.oidc.jwks_source, diff --git a/crates/registry-caseworkctl/src/project.rs b/crates/registry-caseworkctl/src/project.rs index d54505de38..d8afd693ef 100644 --- a/crates/registry-caseworkctl/src/project.rs +++ b/crates/registry-caseworkctl/src/project.rs @@ -186,7 +186,7 @@ expect: "#; /// The local clients `caseworkctl init` writes beside the standalone project. -pub(super) const STANDALONE_DEV_CLIENTS: &str = r#"# Local callers for `caseworkctl dev`. Registry Mint, the local token issuer +pub(super) const STANDALONE_DEV_CLIENTS: &str = r#"# Local callers for `caseworkctl dev`. The pinned local token issuer # that `dev` starts beside Casework, registers each client below and issues it # short-lived tokens carrying these claims. One client binds each access # profile `casework.yaml` declares, so a first start serves every role in the @@ -230,7 +230,7 @@ directory: /// The local clients `caseworkctl init` writes beside the professional-review /// project. That project binds a BReg source, so its runtime needs a reader /// credential `dev` cannot generate; these clients serve a deployed runtime, -/// and `mint` issues their tokens from the operator's own issuer. +/// and the operator's own issuer provides their access tokens. pub(super) const PROFESSIONAL_REVIEW_DEV_CLIENTS: &str = r#"# Local callers for this Casework project. Each client binds one access # profile `casework.yaml` declares and carries the claims that profile reads: # `registry_principal` is this project's `principalClaim`, and diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs index 790f36a153..abe93bd240 100644 --- a/crates/registry-platform-httputil/src/client/private_key_jwt.rs +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -16,12 +16,8 @@ //! configuration's credential to another. //! //! The assertion itself is built by -//! [`registry_platform_authcommon::client_assertion`]. Nothing else in the -//! stack calls that builder yet: `registry-mint`'s own caller tooling -//! (`crates/registry-mint/src/caller.rs`) signs a client assertion for testing -//! Mint's token endpoint, but it builds its own claims, header, and algorithm -//! mapping rather than reusing this one. What this module owns is the token -//! request that presents one and the credential it is exchanged for. +//! [`registry_platform_authcommon::client_assertion`]. This module owns the +//! token request that presents it and the credential it is exchanged for. //! //! # What is cached, and for how long //! From 1e09d847ce79807516ad51ee3255dcad39d9dac0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:11:45 +0700 Subject: [PATCH 020/120] test(casework): run retained lifecycle with stock issuer Signed-off-by: Jeremi Joslin --- .../tests/dev_lifecycle.rs | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/crates/registry-caseworkctl/tests/dev_lifecycle.rs b/crates/registry-caseworkctl/tests/dev_lifecycle.rs index e113aee624..620985bee7 100644 --- a/crates/registry-caseworkctl/tests/dev_lifecycle.rs +++ b/crates/registry-caseworkctl/tests/dev_lifecycle.rs @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 //! Installed-binary proof that `caseworkctl dev` is the whole local runtime. //! -//! Opt in after building `casework`, `mint` and `caseworkctl`; Docker must be +//! Opt in after building `casework` and `caseworkctl`; Docker must be //! available. This creates and removes only its own synthetic database. //! //! ```sh -//! cargo build -p registry-casework -p registry-mint -p registry-caseworkctl +//! cargo build --locked -p registry-casework -p registry-caseworkctl //! cargo test -p registry-caseworkctl --test dev_lifecycle -- --ignored //! ``` @@ -22,7 +22,6 @@ use std::{ struct Session { project: PathBuf, casework_bin: PathBuf, - mint_bin: PathBuf, ports: [u16; 3], _workspace: tempfile::TempDir, } @@ -59,20 +58,18 @@ impl Session { /// a restart proves the session keeps them rather than probing new ones. fn start(&self) -> Value { let project = self.project.to_str().expect("a UTF-8 project path"); - let [casework_port, mint_port, database_port] = self.ports.map(|port| port.to_string()); + let [casework_port, issuer_port, database_port] = self.ports.map(|port| port.to_string()); let report = self.success(&[ "dev", project, "--casework-port", &casework_port, - "--mint-port", - &mint_port, + "--issuer-port", + &issuer_port, "--database-port", &database_port, "--casework-bin", self.casework_bin.to_str().expect("a UTF-8 casework path"), - "--mint-bin", - self.mint_bin.to_str().expect("a UTF-8 mint path"), ]); assert_eq!(report["status"], "ready", "{report:#}"); assert_eq!( @@ -81,7 +78,7 @@ impl Session { ); assert_eq!( report["tokenEndpoint"], - format!("http://127.0.0.1:{mint_port}/token") + format!("http://127.0.0.1:{issuer_port}/oauth2/token") ); report } @@ -93,32 +90,26 @@ impl Session { self.success(&invocation) } - /// A short-lived access token for one local client, obtained exactly as a - /// reader obtains one: the reported token endpoint, the reported client ID - /// file, and the reported private key. The value is never printed. + /// Obtain a fresh token through the maintained local development command. + /// Read its private header file without printing the bearer. fn token(&self, report: &Value, id: &str) -> String { - let client = client(report, id); - let client_id = - fs::read_to_string(client["clientIdFile"].as_str().expect("a client ID file")) - .expect("the reported client ID file is readable"); - let output = Command::new(&self.mint_bin) - .arg("token") - .arg("--url") - .arg(report["tokenEndpoint"].as_str().expect("a token endpoint")) - .arg("--client-id") - .arg(client_id.trim()) - .arg("--key") - .arg(client["assertionKeyFile"].as_str().expect("a key file")) - .output() - .expect("mint launches"); - assert!( - output.status.success(), - "mint refused a token for {id}: {}", - String::from_utf8_lossy(&output.stderr) - ); - let token = String::from_utf8(output.stdout) - .expect("a compact token is ASCII") + let _ = client(report, id); + let issued = self.success(&[ + "dev", + "token", + id, + self.project.to_str().expect("a UTF-8 project path"), + ]); + let header = fs::read_to_string( + issued["headerFile"] + .as_str() + .expect("a private header file"), + ) + .expect("the reported header is readable"); + let token = header .trim() + .strip_prefix("Authorization: Bearer ") + .expect("the local command writes a bearer header") .to_owned(); assert_eq!( token.split('.').count(), @@ -271,13 +262,12 @@ fn inbox(session: &Session, report: &Value) -> Vec { } #[test] -#[ignore = "requires Docker and a built casework and mint"] +#[ignore = "requires Docker and a built casework"] fn dev_serves_a_tutorial_project_and_retains_its_records() { let workspace = tempfile::tempdir().expect("a work directory"); let session = Session { project: workspace.path().join("casework"), casework_bin: prerequisite("casework"), - mint_bin: prerequisite("mint"), ports: free_ports(), _workspace: workspace, }; From cd089192c63d52d0b310b242412bf9b479e6046a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:16:11 +0700 Subject: [PATCH 021/120] build(release): retire Mint from current distribution Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 11 ++--- .github/scripts/test_ci_changes.py | 9 ++-- .github/scripts/test_nightly_rust_coverage.py | 1 - .github/workflows/ci.yml | 3 +- .github/workflows/evidence-dev.yml | 7 ++- .github/workflows/release-canary.yml | 3 +- .github/workflows/release-candidate.yml | 13 ++---- .../src/container.rs | 14 ++++++ release/OPERATIONS.md | 18 ++++---- release/VERIFY.md | 6 ++- release/docker/Dockerfile.mint | 37 --------------- release/manifests/registry-stack-beta-42.yaml | 6 ++- release/notes/v0.30.0.md | 13 ++++-- release/scripts/build-release-binaries.sh | 6 +-- release/scripts/build-release-image.sh | 2 +- .../scripts/build-release-native-platform.sh | 5 +- release/scripts/check-debian13-images.py | 7 --- release/scripts/check-release-source-model.sh | 1 - .../collect-rehearsal-advisory-evidence.py | 2 +- .../scripts/merge-release-binary-shards.py | 5 +- .../merge-release-native-platform-shards.py | 2 + release/scripts/registry-release | 2 + release/scripts/release_candidate.py | 6 ++- .../scripts/smoke-release-image-oci-labels.sh | 2 +- .../scripts/test_check_advisory_baselines.py | 4 -- release/scripts/test_check_debian13_images.py | 2 - .../test_check_release_image_oci_labels.py | 8 ++-- .../test_check_release_source_model.py | 1 - ...est_collect_rehearsal_advisory_evidence.py | 21 ++------- .../test_merge_release_binary_shards.py | 6 +-- release/scripts/test_registry_release.py | 37 +++++++-------- .../scripts/test_registry_release_plans.py | 1 + release/scripts/test_release_candidate.py | 46 +++++++++---------- .../scripts/test_release_native_platform.py | 20 +++++--- .../test_release_workflow_structure.py | 4 +- release/scripts/test_zig_glibc_compiler.py | 28 +++++++---- 36 files changed, 161 insertions(+), 198 deletions(-) delete mode 100644 release/docker/Dockerfile.mint diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 473180ffc6..5875f12215 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -395,14 +395,11 @@ ) # The gate builds and runs exactly these: the Casework runtime, the tool that -# starts and seeds the local session, Registry Mint, because every call the -# reader makes carries a token that session issued, and the Base Registry -# Engine runtime and tool, because the two-product page runs a registry beside -# Casework and connects the two. The clients in the Casework shard are not on -# the replayed path. +# starts and seeds the local session, issuer tooling, and the Base Registry +# Engine runtime and tool used by the source-backed tutorial. CASEWORK_TUTORIAL_PACKAGES = frozenset( - {"registry-casework", "registry-caseworkctl", "registry-breg", "registry-bregctl"} -) | frozenset(SHARDS["mint"]) + {"registry-casework", "registry-caseworkctl", "registry-breg", "registry-bregctl", "registry-thunderid-tooling"} +) # The offline proof of the native BReg to Evidence composition drives bregctl, # evidencectl and the Evidence runtime over the reviewed teaching inputs. It diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 216fef1110..cf3a921a4a 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -1041,15 +1041,12 @@ def test_casework_tutorial_routing(self) -> None: for path in infrastructure: with self.subTest(path=path): self.assertTrue(classify(self.workspace, (path,))["casework_tutorial"]) - # The replay builds and runs these five: the runtime the reader calls, - # the tool that starts and seeds the local session, Registry Mint, - # which issues every token the reader's calls carry, and the Base - # Registry Engine runtime and tool the two-product page runs beside - # Casework. + # The replay builds both product runtimes and tools plus the stock + # issuer tooling used by their source-backed session. for path in ( "crates/registry-casework/src/http.rs", "crates/registry-caseworkctl/src/dev/mod.rs", - "crates/registry-mint/src/lib.rs", + "crates/registry-thunderid-tooling/src/lib.rs", "crates/registry-breg/src/lib.rs", "crates/registry-bregctl/src/dev/mod.rs", ): diff --git a/.github/scripts/test_nightly_rust_coverage.py b/.github/scripts/test_nightly_rust_coverage.py index 6804afec3f..aea55b609d 100644 --- a/.github/scripts/test_nightly_rust_coverage.py +++ b/.github/scripts/test_nightly_rust_coverage.py @@ -41,7 +41,6 @@ def test_all_live_shards_have_stable_flags_and_owned_packages(self) -> None: "casework": "casework", "stack-client": "stack-client", "evidence": "evidence", - "mint": "mint", "developer-tools": "developer-tools", } self.assertEqual({entry["name"]: entry["flag"] for entry in entries}, expected_flags) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67c0b096a3..0823ca1c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1334,7 +1334,7 @@ jobs: # so its binaries are part of the toolset the replay serves. CARGO_TARGET_DIR="target/casework-tutorial-source" \ cargo build --locked --profile ci \ - -p registry-casework -p registry-caseworkctl -p registry-mint \ + -p registry-casework -p registry-caseworkctl \ -p registry-breg --features registry-breg/runtime \ -p registry-bregctl --bins @@ -1353,7 +1353,6 @@ jobs: env: CASEWORK_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/casework CASEWORKCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/caseworkctl - MINT_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/mint BREG_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/breg BREGCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/bregctl run: | diff --git a/.github/workflows/evidence-dev.yml b/.github/workflows/evidence-dev.yml index b13eafe57f..9626f86714 100644 --- a/.github/workflows/evidence-dev.yml +++ b/.github/workflows/evidence-dev.yml @@ -174,11 +174,10 @@ jobs: cargo build --release --locked \ -p registry-evidence \ -p registry-evidencectl \ - -p registry-mint \ -p registry-evidence-oid4vci \ --target "${{ matrix.target }}" mkdir -p development-platform - for binary in evidence evidencectl mint evidence-oid4vci; do + for binary in evidence evidencectl evidence-oid4vci; do asset="${binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/${binary}" \ "development-platform/${asset}" @@ -571,7 +570,7 @@ jobs: EVIDENCECTL_ASSET_DIR="${GITHUB_WORKSPACE}/development-assets" \ EVIDENCECTL_INSTALL_DIR="${install_dir}" \ bash development-assets/evidencectl-install.sh - for binary in evidence evidencectl mint evidence-oid4vci; do + for binary in evidence evidencectl evidence-oid4vci; do observed="$("${install_dir}/${binary}" --version)" test "${observed}" = "${binary} ${{ needs.validate.outputs.version }}-dev" done @@ -607,7 +606,7 @@ jobs: set -euo pipefail tag="${{ needs.validate.outputs.tag }}" for platform in linux-amd64 linux-arm64 macos-arm64; do - for binary in evidence evidencectl mint evidence-oid4vci; do + for binary in evidence evidencectl evidence-oid4vci; do echo "${binary}-${tag}-${platform}" done echo "evidence-client-node-${tag}-${platform}.tgz" diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index 404470d276..c0a4face13 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -69,7 +69,7 @@ jobs: PY printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - image_names=(relay evidence mint discovery breg casework) + image_names=(relay evidence discovery breg casework) write_image_reports() { local name="$1" @@ -107,7 +107,6 @@ jobs: subjects:[ "discovery-image", "evidence-image", - "mint-image", "breg-image", "casework-image", "relay-image" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 4707536c92..2e704452c3 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -496,7 +496,7 @@ jobs: --profile minimal --target "${{ matrix.target }}" cargo build --release --locked \ -p registry-relayctl -p registry-evidence -p registry-evidencectl \ - -p registry-mint -p registry-evidence-oid4vci \ + -p registry-evidence-oid4vci \ --target "${{ matrix.target }}" IFS=. read -r release_major release_minor _release_patch \ <<<"${{ needs.validate.outputs.version }}" @@ -526,7 +526,7 @@ jobs: chmod 0755 "platform/${asset}" test "$("platform/${asset}" --version)" = \ "relayctl ${{ needs.validate.outputs.version }}" - for evidence_binary in evidence evidencectl mint evidence-oid4vci; do + for evidence_binary in evidence evidencectl evidence-oid4vci; do asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/${evidence_binary}" \ "platform/${asset}" @@ -1593,14 +1593,13 @@ jobs: sha256sum -- \ "breg-${{ needs.validate.outputs.tag }}-linux-amd64" \ "bregctl-${{ needs.validate.outputs.tag }}-linux-amd64" \ - "mint-${{ needs.validate.outputs.tag }}-linux-amd64" \ > SHA256SUMS ) breg_install_dir="${RUNNER_TEMP}/candidate-breg-install" BREG_ASSET_DIR="${GITHUB_WORKSPACE}/candidate/bundle-root" \ BREG_INSTALL_DIR="${breg_install_dir}" \ bash "candidate/bundle-root/${breg_installer}" - for breg_binary in breg bregctl mint; do + for breg_binary in breg bregctl; do observed="$("${breg_install_dir}/${breg_binary}" --version)" test "${observed}" = \ "${breg_binary} ${{ needs.validate.outputs.version }}" @@ -1633,14 +1632,13 @@ jobs: sha256sum -- \ "casework-${{ needs.validate.outputs.tag }}-linux-amd64" \ "caseworkctl-${{ needs.validate.outputs.tag }}-linux-amd64" \ - "mint-${{ needs.validate.outputs.tag }}-linux-amd64" \ > SHA256SUMS ) casework_install_dir="${RUNNER_TEMP}/candidate-casework-install" CASEWORK_ASSET_DIR="${GITHUB_WORKSPACE}/candidate/bundle-root" \ CASEWORK_INSTALL_DIR="${casework_install_dir}" \ bash "candidate/bundle-root/${casework_installer}" - for casework_binary in casework caseworkctl mint; do + for casework_binary in casework caseworkctl; do observed="$("${casework_install_dir}/${casework_binary}" --version)" test "${observed}" = \ "${casework_binary} ${{ needs.validate.outputs.version }}" @@ -1676,7 +1674,6 @@ jobs: sha256sum -- \ "evidence-${{ needs.validate.outputs.tag }}-linux-amd64" \ "evidencectl-${{ needs.validate.outputs.tag }}-linux-amd64" \ - "mint-${{ needs.validate.outputs.tag }}-linux-amd64" \ "evidence-oid4vci-${{ needs.validate.outputs.tag }}-linux-amd64" \ > SHA256SUMS ) @@ -1684,7 +1681,7 @@ jobs: EVIDENCECTL_ASSET_DIR="${GITHUB_WORKSPACE}/candidate/bundle-root" \ EVIDENCECTL_INSTALL_DIR="${evidence_install_dir}" \ bash "candidate/bundle-root/${evidencectl_installer}" - for binary in evidence evidencectl mint evidence-oid4vci; do + for binary in evidence evidencectl evidence-oid4vci; do observed="$("${evidence_install_dir}/${binary}" --version)" test "${observed}" = \ "${binary} ${{ needs.validate.outputs.version }}" diff --git a/crates/registry-thunderid-tooling/src/container.rs b/crates/registry-thunderid-tooling/src/container.rs index fa9530592e..7e2d0e2f75 100644 --- a/crates/registry-thunderid-tooling/src/container.rs +++ b/crates/registry-thunderid-tooling/src/container.rs @@ -275,6 +275,13 @@ impl Session<'_> { "--publish".into(), format!("127.0.0.1:{}:8090", self.port), ]; + // Docker Desktop provides this host name itself. Linux Engine needs an + // explicit host gateway so approved local assertion JWKS are reachable. + #[cfg(target_os = "linux")] + args.extend([ + "--add-host".into(), + "host.docker.internal:host-gateway".into(), + ]); for (host, container) in self.serving_mounts() { args.push("--mount".into()); args.push(format!( @@ -504,6 +511,13 @@ mod tests { assert!(runner.commands[0].contains(&format!("name=^/{}$", session.container_name()))); assert_eq!(runner.commands[1], ["rm", "-f", &id]); assert_eq!(runner.commands[2][0], "run"); + assert!(runner.commands[2].contains(&"127.0.0.1:18091:8090".into())); + assert_eq!( + runner.commands[2] + .windows(2) + .any(|pair| pair == ["--add-host", "host.docker.internal:host-gateway"]), + cfg!(target_os = "linux"), + ); runner.commands.clear(); runner.owned_id = None; diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 7bc1d6f101..74743bea27 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -44,9 +44,9 @@ Complete new-image onboarding outside the release clock, in this order: token on the command line: ```sh -package="${PACKAGE:?set PACKAGE to relay, evidence, mint, discovery, breg, or casework}" +package="${PACKAGE:?set PACKAGE to relay, evidence, discovery, breg, or casework}" case "${package}" in - relay|evidence|mint|discovery|breg|casework) ;; + relay|evidence|discovery|breg|casework) ;; *) echo "unsupported release image package: ${package}" >&2; exit 1 ;; esac @@ -102,7 +102,8 @@ printf '%s' "${GHCR_BOOTSTRAP_TOKEN:?set a classic PAT with write:packages}" \ Starting with `v0.21.0`, the release requires public `relay`, `evidence`, and `mint` packages, joined by `discovery` from `v0.24.0`, `breg` from -`v0.26.0`, and `casework` from `v0.30.0`. After selecting the candidate +`v0.26.0`, and `casework` from `v0.30.0`. Mint is retired from `v0.30.0`; +older release inventories remain unchanged. After selecting the candidate version, derive its exact image roster and verify each final destination: ```sh @@ -450,7 +451,7 @@ Download that exact rehearsal artifact and prepare one image's evidence with: rehearsal_run= version= request_id= -name=relay # or evidence, mint, discovery, breg, or casework +name=relay # or evidence, discovery, breg, or casework artifact_dir="rehearsal-advisory-${rehearsal_run}-${name}" test ! -e "${artifact_dir}" gh run download "${rehearsal_run}" \ @@ -460,7 +461,7 @@ gh run download "${rehearsal_run}" \ case "${name}" in relay) baseline=products/relay-v2/security/advisory-baseline.json ;; - breg|casework|discovery|evidence|mint) + breg|casework|discovery|evidence) baseline="release/security/${name}-advisory-baseline.json" ;; *) echo "unsupported release image: ${name}" >&2; exit 2 ;; @@ -598,7 +599,8 @@ workflow then: - Validates the release identity, manifests, pins, recipes, and destinations. - Builds the release payloads and OCI images once. Starting with `v0.21.0`, the image set is Relay, Evidence Gateway, and Registry Mint. Discovery joins at - `v0.24.0`, and Base Registry Engine joins at `v0.26.0`. + `v0.24.0`, Base Registry Engine joins at `v0.26.0`, and Casework joins at + `v0.30.0`. Mint is excluded from `v0.30.0` onward. - Builds the exact locked release documentation archive once, in parallel with binary and client builds, and includes it in the candidate payload closure. - Publishes images only to private candidate packages. @@ -634,7 +636,7 @@ evidence with the scanner versions pinned in the candidate workflow: ```sh run_id= run_attempt= -name=relay # or evidence, mint, discovery, or breg +name=relay # or evidence, discovery, or breg candidate_tag="ghcr.io/registrystack/${name}-candidate:candidate-${run_id}-${run_attempt}" digest="$(crane digest "${candidate_tag}")" candidate_ref="ghcr.io/registrystack/${name}-candidate@${digest}" @@ -664,7 +666,7 @@ prefix of the candidate's authoritative uncompressed DiffIDs: ```sh baseline=products/relay-v2/security/advisory-baseline.json -# BReg, Casework, Discovery, Evidence, and Mint use release/security/-advisory-baseline.json. +# BReg, Casework, Discovery, and Evidence use release/security/-advisory-baseline.json. jq --slurpfile baseline "${baseline}" -e ' .rootfs.diff_ids[0:($baseline[0].runtime.layer_ids | length)] == $baseline[0].runtime.layer_ids diff --git a/release/VERIFY.md b/release/VERIFY.md index 00a7e16086..dbc84121b6 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -107,7 +107,7 @@ jq -e --arg tag "${tag}" ' ($tag | capture("^v(?[0-9]+)\\.(?[0-9]+)\\.") | {major: (.major | tonumber), minor: (.minor | tonumber)}) as $version | (if ($version.major > 0 or $version.minor >= 30) - then ["breg", "casework", "discovery", "evidence", "mint", "relay"] + then ["breg", "casework", "discovery", "evidence", "relay"] elif $version.minor >= 26 then ["breg", "discovery", "evidence", "mint", "relay"] elif $version.minor >= 24 then ["discovery", "evidence", "mint", "relay"] elif $version.minor >= 21 then ["evidence", "mint", "relay"] @@ -132,6 +132,7 @@ jq -e --arg tag "${tag}" ' Starting with `v0.21.0`, the exact image set is Evidence Gateway, Registry Mint, and Registry Relay. Registry Discovery joins at `v0.24.0`, and Base Registry Engine joins at `v0.26.0`, and Registry Casework joins at `v0.30.0`. +Mint is retired from `v0.30.0`; historical releases retain their original roster. The final release tags recorded in the manifest must resolve to the same digests as their candidate bindings: @@ -174,7 +175,8 @@ tar -tzf "${evidence}" Starting with `v0.21.0`, the archive contains image-specific SPDX and Syft reports and Grype reports for `evidence`, `mint`, and `relay`, joined by `discovery` from `v0.24.0`, `breg` from `v0.26.0`, and `casework` from -`v0.30.0`; `v0.19.x` and `v0.20.x` archives contain those reports for `relay` +`v0.30.0`. Mint reports are excluded from `v0.30.0` onward; `v0.19.x` and +`v0.20.x` archives contain those reports for `relay` only. The archive also contains the advisory verdict used for candidate acceptance. Each report names the exact candidate digest that was promoted. The archive hash is covered by diff --git a/release/docker/Dockerfile.mint b/release/docker/Dockerfile.mint deleted file mode 100644 index fd47a15327..0000000000 --- a/release/docker/Dockerfile.mint +++ /dev/null @@ -1,37 +0,0 @@ -# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e - -ARG SOURCE_DATE_EPOCH=0 - -FROM debian:trixie-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258 AS runtime-root -ARG SOURCE_DATE_EPOCH - -RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ - --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ - mkdir -p \ - /workspace/runtime-root/etc/registry-mint \ - /workspace/runtime-root/licenses/mint \ - /workspace/runtime-root/usr/local/bin \ - /workspace/runtime-root/var/lib/registry-mint/audit \ - && install -m 0755 /workspace/image-bin/mint /workspace/runtime-root/usr/local/bin/mint \ - && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/mint/LICENSE \ - && chown -R 65532:65532 /workspace/runtime-root/var/lib/registry-mint \ - && chmod 0700 /workspace/runtime-root/var/lib/registry-mint/audit \ - && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + - -FROM gcr.io/distroless/cc-debian13:nonroot@sha256:c31ff9abcb1910f3ab25c7957bdaf0bfe12a01eb546e8df2282f1c8f682b606c AS runtime - -LABEL org.registrystack.runtime.uid="65532" \ - org.registrystack.runtime.gid="65532" - -COPY --from=runtime-root /workspace/runtime-root/ / - -WORKDIR /var/lib/registry-mint - -ENV MINT_CONFIG=/etc/registry-mint/config.yaml - -EXPOSE 8081 - -# Mint serves GET /health for the platform's HTTP probe. The Distroless image -# has no shell or HTTP client, and Mint has no healthcheck subcommand. -ENTRYPOINT ["/usr/local/bin/mint"] -CMD ["serve"] diff --git a/release/manifests/registry-stack-beta-42.yaml b/release/manifests/registry-stack-beta-42.yaml index 9133fac178..a78c43a368 100644 --- a/release/manifests/registry-stack-beta-42.yaml +++ b/release/manifests/registry-stack-beta-42.yaml @@ -10,7 +10,6 @@ artifacts: relayctl: 0.30.0 evidence: 0.30.0 evidencectl: 0.30.0 - mint: 0.30.0 evidence-oid4vci: 0.30.0 evidencectl-installer: 0.30.0 relay-installer: 0.30.0 @@ -31,3 +30,8 @@ identifier_catalog: entry_count: 112 external: {} + +warnings: + - code: registry-mint-retired + classification: breaking-product-replacement + detail: Registry Mint binaries and images are no longer shipped. Configure a standards-based OAuth issuer; maintained local tooling uses pinned stock ThunderID 1.0.1. Existing Mint configuration and signing custody or durable pre-response issuance audit are not migrated. diff --git a/release/notes/v0.30.0.md b/release/notes/v0.30.0.md index 26ab1d508a..d8a693fd37 100644 --- a/release/notes/v0.30.0.md +++ b/release/notes/v0.30.0.md @@ -6,13 +6,20 @@ capability coverage, and updates Evidence and Casework configuration workflows. ## Compatibility and migration +- Registry Mint is retired. This release ships no Mint binary or container. + Configure a standards-based OAuth issuer; the maintained local development + commands use pinned stock ThunderID 1.0.1. Mint configuration is not migrated. + This replacement does not preserve Mint external signing-key custody or its + durable pre-response issuance audit. Deployments requiring those properties + must select an issuer that supplies them before upgrading. + - Registry Casework is a new product in this release. The pinned - `casework-install.sh` installs matching `casework`, `caseworkctl`, and `mint` + `casework-install.sh` installs matching `casework` and `caseworkctl` binaries. A Casework deployment needs its own PostgreSQL database, token verification, and explicit access profiles. A source-backed project also needs a governed Base Registry Engine source and - a configured source profile. Use Mint when the deployment has no identity - provider; `caseworkctl dev` provides a maintained local-development path. + a configured source profile. Configure an OAuth issuer for deployed access; + `caseworkctl dev` uses pinned stock ThunderID 1.0.1 for local development. - Casework operator configuration uses `casework-runtime/v1alpha1`. Set an absolute `package.root` for the authored `casework.yaml`, put network fields under `listener`, configure secret providers explicitly, and use diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index f25c2b6b11..c25469b082 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -151,14 +151,11 @@ build_payload() { cargo build --release --locked \ -p registry-evidence \ -p registry-evidencectl \ - -p registry-mint \ -p registry-evidence-oid4vci cp target/release/evidence "dist/bin/evidence-${RELEASE_TAG}-linux-amd64" cp target/release/evidencectl "dist/bin/evidencectl-${RELEASE_TAG}-linux-amd64" - cp target/release/mint "dist/bin/mint-${RELEASE_TAG}-linux-amd64" cp target/release/evidence-oid4vci "dist/bin/evidence-oid4vci-${RELEASE_TAG}-linux-amd64" cp target/release/evidence dist/image-bin/evidence - cp target/release/mint dist/image-bin/mint if [[ "${include_discovery}" -eq 1 ]]; then cargo build --release --locked \ @@ -332,13 +329,12 @@ if [[ "${group}" == all || "${group}" == core ]]; then bin_assets+=( "evidence-${tag}-linux-amd64" "evidencectl-${tag}-linux-amd64" - "mint-${tag}-linux-amd64" "evidence-oid4vci-${tag}-linux-amd64" "registry-manifest-${tag}-linux-amd64" "relay-${tag}-linux-amd64" "relayctl-${tag}-linux-amd64" ) - image_bin_binaries+=(evidence mint relay) + image_bin_binaries+=(evidence relay) fi for asset in "${bin_assets[@]}"; do diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index 161ba898ef..cccab940c8 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -24,7 +24,7 @@ release_image_context="${RELEASE_IMAGE_CONTEXT:-${repo_root}}" created_builder=false case "${name}" in - discovery|evidence|mint|breg|casework|relay) + discovery|evidence|breg|casework|relay) dockerfile="${repo_root}/release/docker/Dockerfile.${name}" ;; *) diff --git a/release/scripts/build-release-native-platform.sh b/release/scripts/build-release-native-platform.sh index e97190fa8a..d089b4e109 100755 --- a/release/scripts/build-release-native-platform.sh +++ b/release/scripts/build-release-native-platform.sh @@ -94,14 +94,14 @@ stage() { build_core() { "${cargo_bin}" build --release --locked \ -p registry-relayctl -p registry-evidence -p registry-evidencectl \ - -p registry-mint -p registry-evidence-oid4vci \ + -p registry-evidence-oid4vci \ --target "${target}" stage relayctl "relayctl-${tag}-${asset}" test "$("${temporary}/platform/relayctl-${tag}-${asset}" --version)" = \ "relayctl ${version}" local binary - for binary in evidence evidencectl mint evidence-oid4vci; do + for binary in evidence evidencectl evidence-oid4vci; do stage "${binary}" "${binary}-${tag}-${asset}" done } @@ -171,7 +171,6 @@ if [[ "${group}" == core || "${group}" == all ]]; then "relayctl-${tag}-${asset}" "evidence-${tag}-${asset}" "evidencectl-${tag}-${asset}" - "mint-${tag}-${asset}" "evidence-oid4vci-${tag}-${asset}" ) fi diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index 192cc86a07..7067a11188 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -47,7 +47,6 @@ DOCKERFILES = ( Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), - Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), @@ -125,12 +124,6 @@ "entrypoint": 'ENTRYPOINT ["/usr/local/bin/evidence"]', "command": 'CMD ["serve"]', }, - Path("release/docker/Dockerfile.mint"): { - "binary": "mint", - "environment": "ENV MINT_CONFIG=/etc/registry-mint/config.yaml", - "entrypoint": 'ENTRYPOINT ["/usr/local/bin/mint"]', - "command": 'CMD ["serve"]', - }, Path("release/docker/Dockerfile.breg"): { "binary": "breg", "entrypoint": 'ENTRYPOINT ["/usr/local/bin/breg"]', diff --git a/release/scripts/check-release-source-model.sh b/release/scripts/check-release-source-model.sh index a464f8af26..2f6c35784d 100755 --- a/release/scripts/check-release-source-model.sh +++ b/release/scripts/check-release-source-model.sh @@ -73,7 +73,6 @@ require_path "registry-relay Node client binding" "${stack_root}/crates/registry require_path "registry-relay Python client binding" "${stack_root}/crates/registry-relay-client-py" 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" require_path "registry-evidence-oid4vci crate" "${stack_root}/crates/registry-evidence-oid4vci" require_path "registry-breg crate" "${stack_root}/crates/registry-breg" require_path "registry-bregctl crate" "${stack_root}/crates/registry-bregctl" diff --git a/release/scripts/collect-rehearsal-advisory-evidence.py b/release/scripts/collect-rehearsal-advisory-evidence.py index 8d7adac141..f8ba7b1898 100755 --- a/release/scripts/collect-rehearsal-advisory-evidence.py +++ b/release/scripts/collect-rehearsal-advisory-evidence.py @@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[2] -IMAGE_NAMES = frozenset({"breg", "casework", "discovery", "evidence", "mint", "relay"}) +IMAGE_NAMES = frozenset({"breg", "casework", "discovery", "evidence", "relay"}) SEMVER_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}") REVISION_RE = re.compile(r"[0-9a-f]{40}") diff --git a/release/scripts/merge-release-binary-shards.py b/release/scripts/merge-release-binary-shards.py index 3dde9ebc2d..98f158c109 100755 --- a/release/scripts/merge-release-binary-shards.py +++ b/release/scripts/merge-release-binary-shards.py @@ -53,9 +53,12 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: f"relay-{tag}-linux-amd64", f"relayctl-{tag}-linux-amd64", ] + if parsed >= (0, 30, 0): + common.remove(f"mint-{tag}-linux-amd64") core.extend(common) for image_name in ("evidence", "mint", "relay"): - image_bins.append((image_name, f"{image_name}-{tag}-linux-amd64")) + if image_name != "mint" or parsed < (0, 30, 0): + image_bins.append((image_name, f"{image_name}-{tag}-linux-amd64")) return {"core": core, "breg": breg, "casework": casework}, image_bins diff --git a/release/scripts/merge-release-native-platform-shards.py b/release/scripts/merge-release-native-platform-shards.py index 8884e5da6f..c3b4c1296d 100755 --- a/release/scripts/merge-release-native-platform-shards.py +++ b/release/scripts/merge-release-native-platform-shards.py @@ -39,6 +39,8 @@ def rosters(version: str) -> dict[str, list[str]]: f"mint-{tag}-{ASSET}", f"evidence-oid4vci-{tag}-{ASSET}", ] + if parsed >= (0, 30, 0): + core.remove(f"mint-{tag}-{ASSET}") breg = [] bregctl = [] if parsed >= (0, 26, 0): diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 9084d90e15..e5c5d70fa9 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -175,6 +175,8 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s if parsed_version is not None: artifact_names = set(artifacts) expected_inventory = set(RELAY_V2_ARTIFACT_INVENTORY) + if parsed_version >= (0, 30, 0): + expected_inventory.remove("mint") if parsed_version >= RELAY_INSTALLER_MINIMUM_VERSION: expected_inventory.add("relay-installer") if parsed_version >= DOCS_RELEASE_RESUMPTION_VERSION: diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 795b7f537a..109c38f335 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -55,7 +55,8 @@ BREG_RUNTIME_IMAGE_NAMES = DISCOVERY_RUNTIME_IMAGE_NAMES | { "breg" } -CASEWORK_RUNTIME_IMAGE_NAMES = BREG_RUNTIME_IMAGE_NAMES | {"casework"} +MINT_RETIREMENT_VERSION = (0, 30, 0) +CASEWORK_RUNTIME_IMAGE_NAMES = (BREG_RUNTIME_IMAGE_NAMES - {"mint"}) | {"casework"} V2_TOP_LEVEL_FIELDS = { "schema_version", "repository", @@ -293,6 +294,9 @@ def _relay_v2_payload_inventory(version: str) -> dict[str, str]: "evidence-oid4vci", ): inventory[f"{name}-{tag}-{platform}"] = "binary" + if version_tuple >= MINT_RETIREMENT_VERSION: + for platform in ("linux-amd64", "linux-arm64", "macos-arm64"): + inventory.pop(f"mint-{tag}-{platform}") unified_client = version_tuple >= UNIFIED_CLIENT_PACKAGE_MINIMUM_VERSION if not unified_client: for platform in ("linux-amd64-glibc", "linux-arm64-glibc", "macos-arm64"): diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 04d9170e35..7b2507ccc1 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -6,7 +6,7 @@ repo_root="$(cd -- "${script_dir}/../.." && pwd)" checker="${script_dir}/check-release-image-oci-labels.py" image_builder="${script_dir}/build-release-image.sh" layout_comparator="${script_dir}/compare-release-image-layouts.py" -images=(relay evidence mint discovery breg casework) +images=(relay evidence discovery breg casework) relay_dockerfile="${repo_root}/release/docker/Dockerfile.relay" source_label="https://github.com/registrystack/registry-stack" diff --git a/release/scripts/test_check_advisory_baselines.py b/release/scripts/test_check_advisory_baselines.py index 05065537b9..8242ac6bc9 100644 --- a/release/scripts/test_check_advisory_baselines.py +++ b/release/scripts/test_check_advisory_baselines.py @@ -24,7 +24,6 @@ ROOT / "release/security/casework-advisory-baseline.json", ROOT / "release/security/discovery-advisory-baseline.json", ROOT / "release/security/evidence-advisory-baseline.json", - ROOT / "release/security/mint-advisory-baseline.json", ) LIVE_REFERENCE_IMAGE_DIGESTS = { "relay": "sha256:7d25a3324cfc459dbdc4f85a67fb0758a948230535a5b4c0d84b7bd05a614a0a", @@ -32,7 +31,6 @@ "casework": "sha256:f2618e193c83f07501ec0af237dc7841c8cc279f06c7121f04815889fc90a7e2", "discovery": "sha256:9151a7242173be13ee42cabfcc3604efaebc2561efc3d70881a580e10b78e36e", "evidence": "sha256:0ff66e94c746ccad1fcc1913f2f0eb46e6fb21c590b27a9a0fbde60f21bc260b", - "mint": "sha256:d485eb3b24eff8d0aaea73afdeb28984e38bddc9a52a0512228a2e77fbfb7bbe", } LIVE_REFERENCE_SOURCE_REVISION = "6bdeb66a8e37693ef13e0fa629e551a75f109f0c" # The date the live exceptions below were reviewed against, stated here rather @@ -46,7 +44,6 @@ "casework": "local_reproduction", "discovery": "local_reproduction", "evidence": "local_reproduction", - "mint": "local_reproduction", } LIVE_EXECUTABLES = { "relay": "/usr/local/bin/relay", @@ -54,7 +51,6 @@ "casework": "/usr/local/bin/casework", "discovery": "/usr/local/bin/discovery", "evidence": "/usr/local/bin/evidence", - "mint": "/usr/local/bin/mint", } diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py index 3d821aa216..c12503372c 100644 --- a/release/scripts/test_check_debian13_images.py +++ b/release/scripts/test_check_debian13_images.py @@ -56,7 +56,6 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: { Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), - Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), @@ -67,7 +66,6 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: { Path("release/docker/Dockerfile.discovery"), Path("release/docker/Dockerfile.evidence"), - Path("release/docker/Dockerfile.mint"), Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), }, diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 0f57eec7fe..faf095eafb 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -620,7 +620,7 @@ def read_calls(path: Path) -> list[list[str]]: build_calls = [ call for call in read_calls(docker_log) if call[:2] == ["buildx", "build"] ] - self.assertEqual(14, len(build_calls)) + self.assertEqual(12, len(build_calls)) dockerfiles = [] for call in build_calls: self.assertEqual(["buildx", "build"], call[:2]) @@ -647,7 +647,6 @@ def read_calls(path: Path) -> list[list[str]]: { str(ROOT / "release/docker/Dockerfile.discovery"), str(ROOT / "release/docker/Dockerfile.evidence"), - str(ROOT / "release/docker/Dockerfile.mint"), str(ROOT / "release/docker/Dockerfile.breg"), str(ROOT / "release/docker/Dockerfile.casework"), str(ROOT / "release/docker/Dockerfile.relay"), @@ -660,7 +659,7 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.relay") ), ) - for name in ("discovery", "evidence", "mint", "breg", "casework"): + for name in ("discovery", "evidence", "breg", "casework"): self.assertEqual( 2, dockerfiles.count( @@ -679,7 +678,6 @@ def read_calls(path: Path) -> list[list[str]]: { "correct-discovery-first", "correct-evidence-first", - "correct-mint-first", "correct-breg-first", "correct-casework-first", "correct-relay-first", @@ -705,7 +703,7 @@ def read_calls(path: Path) -> list[list[str]]: for call in python_calls if call and call[0].endswith("compare-release-image-layouts.py") ] - self.assertEqual(7, len(comparisons)) + self.assertEqual(6, len(comparisons)) self.assertEqual(1, sum("--rootfs-only" in call for call in comparisons)) diff --git a/release/scripts/test_check_release_source_model.py b/release/scripts/test_check_release_source_model.py index edd31630ba..8a51f0aac7 100644 --- a/release/scripts/test_check_release_source_model.py +++ b/release/scripts/test_check_release_source_model.py @@ -251,7 +251,6 @@ def __enter__(self) -> Path: "crates/registry-relay-client-py", "crates/registry-evidence", "crates/registry-evidencectl", - "crates/registry-mint", "crates/registry-evidence-oid4vci", "crates/registry-breg", "crates/registry-bregctl", diff --git a/release/scripts/test_collect_rehearsal_advisory_evidence.py b/release/scripts/test_collect_rehearsal_advisory_evidence.py index 802ecad386..30892eb5d9 100644 --- a/release/scripts/test_collect_rehearsal_advisory_evidence.py +++ b/release/scripts/test_collect_rehearsal_advisory_evidence.py @@ -24,7 +24,7 @@ class FakeCommands: revision = "b" * 40 source = "https://github.com/registrystack/registry-stack" version = "0.26.1" - roster = ("breg", "discovery", "evidence", "mint", "relay") + roster = ("breg", "discovery", "evidence", "relay") def __init__( self, @@ -192,20 +192,9 @@ def test_rejects_invalid_duplicate_or_unsupported_rosters(self) -> None: with self.assertRaises(MODULE.EvidenceError): MODULE.parse_roster(roster) - def test_v0_26_roster_is_owned_and_complete(self) -> None: - result = subprocess.run( - [ - "python3", - str(ROOT / "release/scripts/release_candidate.py"), - "image-names", - "--version", - "0.26.1", - ], - check=True, - capture_output=True, - text=True, - ) - self.assertEqual(MODULE.parse_roster(result.stdout), FakeCommands.roster) + def test_retired_mint_is_not_an_admitted_rehearsal_image(self) -> None: + with self.assertRaises(MODULE.EvidenceError): + MODULE.parse_roster("evidence mint relay\n") def test_v0_30_roster_is_owned_and_complete(self) -> None: result = subprocess.run( @@ -222,7 +211,7 @@ def test_v0_30_roster_is_owned_and_complete(self) -> None: ) self.assertEqual( MODULE.parse_roster(result.stdout), - ("breg", "casework", "discovery", "evidence", "mint", "relay"), + ("breg", "casework", "discovery", "evidence", "relay"), ) def test_collects_every_owned_image_with_exact_daemon_context(self) -> None: diff --git a/release/scripts/test_merge_release_binary_shards.py b/release/scripts/test_merge_release_binary_shards.py index 61d38f3cea..01f7cfc0e7 100644 --- a/release/scripts/test_merge_release_binary_shards.py +++ b/release/scripts/test_merge_release_binary_shards.py @@ -24,7 +24,6 @@ f"discovery-{TAG}-linux-amd64", f"evidence-{TAG}-linux-amd64", f"evidencectl-{TAG}-linux-amd64", - f"mint-{TAG}-linux-amd64", f"evidence-oid4vci-{TAG}-linux-amd64", f"registry-manifest-{TAG}-linux-amd64", f"relay-{TAG}-linux-amd64", @@ -41,8 +40,7 @@ "breg": BREG[0], "casework": CASEWORK[0], "evidence": CORE[1], - "mint": CORE[3], - "relay": CORE[6], + "relay": CORE[5], } @@ -157,7 +155,7 @@ def test_version_gates_select_the_historical_exact_rosters(self) -> None: rosters_030, images_030 = MODULE.rosters("0.30.0") self.assertEqual(2, len(rosters_030["casework"])) self.assertEqual( - ["discovery", "breg", "casework", "evidence", "mint", "relay"], + ["discovery", "breg", "casework", "evidence", "relay"], [name for name, _ in images_030], ) diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 4e2983dd70..6cb1e2396e 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -920,7 +920,6 @@ def test_release_image_packaging_uses_release_dockerfiles(self) -> None: release_dockerfiles = [ "release/docker/Dockerfile.discovery", "release/docker/Dockerfile.evidence", - "release/docker/Dockerfile.mint", "release/docker/Dockerfile.breg", "release/docker/Dockerfile.casework", "release/docker/Dockerfile.relay", @@ -976,7 +975,7 @@ def test_release_builds_and_packages_evidence_oid4vci_on_every_platform(self) -> self.assertIn("-p registry-evidence-oid4vci", platform_job) self.assertIn( - "for evidence_binary in evidence evidencectl mint evidence-oid4vci", + "for evidence_binary in evidence evidencectl evidence-oid4vci", platform_job, ) self.assertIn("-p registry-evidence-oid4vci", linux_recipe) @@ -985,13 +984,13 @@ def test_release_builds_and_packages_evidence_oid4vci_on_every_platform(self) -> linux_recipe, ) self.assertIn( - "binaries=(evidence evidencectl mint evidence-oid4vci)", installer + "binaries=(evidence evidencectl evidence-oid4vci)", installer ) self.assertIn( 'EVIDENCECTL_INSTALL_DIR="${evidence_install_dir}"', workflow ) self.assertIn( - "for binary in evidence evidencectl mint evidence-oid4vci", + "for binary in evidence evidencectl evidence-oid4vci", workflow, ) @@ -1023,13 +1022,13 @@ def test_release_builds_installs_and_smokes_breg_from_v0_26(self) -> None: self.assertIn('init "${breg_project}"', assemble) self.assertIn('check "${breg_project}"', assemble) self.assertIn( - "binaries=(breg bregctl mint)", installer_text + "binaries=(breg bregctl)", installer_text ) self.assertIn( - '"mint-${{ needs.validate.outputs.tag }}-linux-amd64" \\\n > SHA256SUMS', + '"bregctl-${{ needs.validate.outputs.tag }}-linux-amd64" \\\n > SHA256SUMS', assemble, ) - self.assertIn("for breg_binary in breg bregctl mint; do", assemble) + self.assertIn("for breg_binary in breg bregctl; do", assemble) with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -1051,7 +1050,7 @@ def test_release_builds_installs_and_smokes_breg_from_v0_26(self) -> None: else: raise SkipTest(f"installer has no release asset for {os_name}/{architecture}") checksums = [] - for binary in ("breg", "bregctl", "mint"): + for binary in ("breg", "bregctl"): name = f"{binary}-v0.26.0-{platform_name}" body = f"{binary} fixture\n".encode() (assets / name).write_bytes(body) @@ -1073,7 +1072,7 @@ def test_release_builds_installs_and_smokes_breg_from_v0_26(self) -> None: check=False, ) self.assertEqual(0, result.returncode, result.stderr) - for binary in ("breg", "bregctl", "mint"): + for binary in ("breg", "bregctl"): installed = destination / binary self.assertEqual(f"{binary} fixture\n", installed.read_text()) self.assertTrue(installed.stat().st_mode & stat.S_IXUSR) @@ -1088,18 +1087,17 @@ def test_release_builds_installs_and_smokes_casework_from_v0_30(self) -> None: self.assertIn("release_minor >= 30", workflow) self.assertIn("-p registry-casework --bin casework", workflow) self.assertIn("-p registry-caseworkctl --bin caseworkctl", workflow) - self.assertIn("binaries=(casework caseworkctl mint)", installer_text) + self.assertIn("binaries=(casework caseworkctl)", installer_text) self.assertIn("CASEWORK_ASSET_DIR", workflow) self.assertIn("CASEWORK_INSTALL_DIR", workflow) self.assertIn("--template standalone-decision", workflow) self.assertIn( '"caseworkctl-${{ needs.validate.outputs.tag }}-linux-amd64" \\\n' - ' "mint-${{ needs.validate.outputs.tag }}-linux-amd64" \\\n' " > SHA256SUMS", assemble, ) self.assertIn( - "for casework_binary in casework caseworkctl mint; do", assemble + "for casework_binary in casework caseworkctl; do", assemble ) self.assertIn('caseworkctl\" check', workflow) self.assertIn('caseworkctl\" test', workflow) @@ -1127,7 +1125,7 @@ def test_release_builds_installs_and_smokes_casework_from_v0_30(self) -> None: f"installer has no release asset for {os_name}/{architecture}" ) checksums = [] - for binary in ("casework", "caseworkctl", "mint"): + for binary in ("casework", "caseworkctl"): name = f"{binary}-v0.30.0-{platform_name}" body = f"{binary} fixture\n".encode() (assets / name).write_bytes(body) @@ -1149,7 +1147,7 @@ def test_release_builds_installs_and_smokes_casework_from_v0_30(self) -> None: check=False, ) self.assertEqual(0, result.returncode, result.stderr) - for binary in ("casework", "caseworkctl", "mint"): + for binary in ("casework", "caseworkctl"): installed = destination / binary self.assertEqual(f"{binary} fixture\n", installed.read_text()) self.assertTrue(installed.stat().st_mode & stat.S_IXUSR) @@ -1382,12 +1380,11 @@ def test_release_canary_exercises_the_current_image_contract(self) -> None: for current in ( "_relay_v2_payload_inventory", "payloads: $payloads[0]", - "image_names=(relay evidence mint discovery breg casework)", + "image_names=(relay evidence discovery breg casework)", "images: $images[0]", "scans: $scans[0]", '"discovery-image"', '"evidence-image"', - '"mint-image"', '"breg-image"', '"casework-image"', '"relay-image"', @@ -1460,7 +1457,6 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: for name in ( "discovery", "evidence", - "mint", "breg", "casework", "relay", @@ -1485,7 +1481,6 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: for name in ( "discovery", "evidence", - "mint", "breg", "casework", "relay", @@ -1499,7 +1494,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: f"/workspace/runtime-root/usr/local/bin/{name}", release_dockerfiles[name], ) - self.assertIn("discovery|evidence|mint|breg|casework|relay)", image_recipe) + self.assertIn("discovery|evidence|breg|casework|relay)", image_recipe) self.assertNotIn("registry-relay)", image_recipe) def test_breg_release_image_keeps_deployment_inputs_external(self) -> None: @@ -1709,7 +1704,7 @@ def test_casework_release_surface_begins_after_published_v0_29(self) -> None: for name in published_names } self.assertEqual([], module.artifact_inventory_errors("0.29.0", published)) - future = {name: "0.30.0" for name in published} + future = {name: "0.30.0" for name in published if name != "mint"} future.update( { "casework": "0.30.0", @@ -1776,7 +1771,6 @@ def test_release_product_images_preown_managed_audit_and_state_directories( ) -> None: contracts = { "evidence": "/workspace/runtime-root/var/lib/registry-evidence/audit", - "mint": "/workspace/runtime-root/var/lib/registry-mint/audit", "relay": "/workspace/runtime-root/var/lib/relay/audit", } for name, audit_path in contracts.items(): @@ -3050,6 +3044,7 @@ def write_manifest( artifacts["registry-client-node"] = version artifacts["registry-client-python"] = version if version_tuple >= (0, 30, 0): + artifacts.pop("mint") artifacts["casework"] = version artifacts["caseworkctl"] = version artifacts["casework-installer"] = version diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 287562d0a7..3eae5878bf 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -138,6 +138,7 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic "breg-installer", ) if version_tuple >= CASEWORK_RELEASE_MINIMUM_VERSION: + inventory = tuple(name for name in inventory if name != "mint") inventory += ( "casework", "caseworkctl", diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index c8cbe9893e..49c9155b0b 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -46,7 +46,7 @@ def security_evidence_members( image_names: tuple[str, ...] = ( "discovery", "evidence", - "mint", + "casework", "breg", "relay", ), @@ -155,7 +155,7 @@ def onboarding_repository(self) -> Path: "release/scripts/build-release-image.sh", "release/scripts/cleanup-release-candidates.py", ] - for image_name in self.module._candidate_image_names("0.26.0"): + for image_name in self.module._candidate_image_names("0.30.0"): paths.append(f"release/docker/Dockerfile.{image_name}") paths.append( "products/relay-v2/security/advisory-baseline.json" @@ -378,7 +378,6 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: image_names = ( "discovery", "evidence", - "mint", "breg", "casework", "relay", @@ -840,7 +839,7 @@ def test_breg_image_joins_the_roster_at_v0_26(self) -> None: def test_casework_joins_only_the_future_v0_30_roster(self) -> None: self.assertNotIn("casework", self.module._candidate_image_names("0.29.0")) self.assertEqual( - {"breg", "casework", "discovery", "evidence", "mint", "relay"}, + {"breg", "casework", "discovery", "evidence", "relay"}, self.module._candidate_image_names("0.30.0"), ) historical = self.module._relay_v2_payload_inventory("0.29.0") @@ -859,7 +858,7 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: ("0.24.0", "discovery evidence mint relay\n"), ("0.26.0", "breg discovery evidence mint relay\n"), ("0.29.0", "breg discovery evidence mint relay\n"), - ("0.30.0", "breg casework discovery evidence mint relay\n"), + ("0.30.0", "breg casework discovery evidence relay\n"), ) for version, expected in cases: with self.subTest(version=version): @@ -872,7 +871,7 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: self.assertEqual(expected, stdout.getvalue()) def test_image_onboarding_accepts_every_current_version_roster(self) -> None: - for version in ("0.20.0", "0.21.0", "0.24.0", "0.26.0"): + for version in ("0.30.0",): with self.subTest(version=version): self.assertEqual( self.module._candidate_image_names(version), @@ -881,10 +880,7 @@ def test_image_onboarding_accepts_every_current_version_roster(self) -> None: def test_image_onboarding_at_0_30_0_requires_reviewed_casework_baseline(self) -> None: root = self.onboarding_repository() - shutil.copy2( - ROOT / "release/docker/Dockerfile.casework", - root / "release/docker/Dockerfile.casework", - ) + (root / "release/security/casework-advisory-baseline.json").unlink() with self.assertRaisesRegex( self.module.CandidateError, "casework advisory baseline is missing", @@ -920,7 +916,7 @@ def test_image_onboarding_rejects_a_missing_dockerfile(self) -> None: ): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) @@ -943,7 +939,7 @@ def test_image_onboarding_rejects_symlinked_release_inputs(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) shutil.rmtree(root) @@ -953,7 +949,7 @@ def test_image_onboarding_rejects_an_unsupported_build_name(self) -> None: recipe = root / "release/scripts/build-release-image.sh" recipe.write_text( recipe.read_text(encoding="utf-8").replace( - "discovery|evidence|mint|breg|casework|relay", + "discovery|evidence|breg|casework|relay", "discovery|evidence|mint|relay", ), encoding="utf-8", @@ -964,7 +960,7 @@ def test_image_onboarding_rejects_an_unsupported_build_name(self) -> None: ): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) @@ -984,7 +980,7 @@ def test_image_onboarding_rejects_a_missing_binary_staging_path(self) -> None: ): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) @@ -996,10 +992,10 @@ def test_only_an_absent_baseline_has_an_explicit_allowance(self) -> None: self.module.CandidateError, "breg advisory baseline is missing", ): - self.module.check_image_onboarding(root, "0.26.0") + self.module.check_image_onboarding(root, "0.30.0") self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) cleanup = root / "release/scripts/cleanup-release-candidates.py" @@ -1016,7 +1012,7 @@ def test_only_an_absent_baseline_has_an_explicit_allowance(self) -> None: ): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) @@ -1031,7 +1027,7 @@ def test_image_onboarding_cli_reports_strict_failure_and_bootstrap_success( [ "check-image-onboarding", "--version", - "0.26.0", + "0.30.0", "--root", str(root), ] @@ -1044,7 +1040,7 @@ def test_image_onboarding_cli_reports_strict_failure_and_bootstrap_success( [ "check-image-onboarding", "--version", - "0.26.0", + "0.30.0", "--root", str(root), "--allow-missing-baseline", @@ -1052,7 +1048,7 @@ def test_image_onboarding_cli_reports_strict_failure_and_bootstrap_success( ) self.assertEqual(0, result) self.assertEqual( - "checked image onboarding for breg discovery evidence mint relay\n", + "checked image onboarding for breg casework discovery evidence relay\n", stdout.getvalue(), ) @@ -1066,7 +1062,7 @@ def test_image_onboarding_rejects_a_malformed_baseline(self) -> None: ): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) @@ -1082,7 +1078,7 @@ def test_image_onboarding_requires_v4_for_the_exact_service(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) shutil.rmtree(root) @@ -1110,7 +1106,7 @@ def test_image_onboarding_requires_structural_cleanup_identities(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.26.0", + "0.30.0", allow_missing_baseline=True, ) shutil.rmtree(root) @@ -1275,7 +1271,7 @@ def test_v2_security_evidence_archive_requires_every_expected_file( candidate, _, bundle_root, _ = self.make_v2_candidate() members = security_evidence_members() required = self.module._security_evidence_required_files( - self.module.DISCOVERY_RUNTIME_IMAGE_NAMES + self.module.CASEWORK_RUNTIME_IMAGE_NAMES ) for missing in sorted(required): with self.subTest(missing=missing): diff --git a/release/scripts/test_release_native_platform.py b/release/scripts/test_release_native_platform.py index 2bb01145c1..75da18dff8 100755 --- a/release/scripts/test_release_native_platform.py +++ b/release/scripts/test_release_native_platform.py @@ -20,7 +20,7 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) -VERSION = "0.27.0" +VERSION = "0.30.0" SOURCE_SHA = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=ROOT, @@ -40,8 +40,6 @@ "-p", "registry-evidencectl", "-p", - "registry-mint", - "-p", "registry-evidence-oid4vci", "--target", TARGET, @@ -130,7 +128,6 @@ def setUp(self) -> None: "registry-relayctl": "relayctl", "registry-evidence": "evidence", "registry-evidencectl": "evidencectl", - "registry-mint": "mint", "registry-evidence-oid4vci": "evidence-oid4vci", "registry-breg": "breg", "registry-bregctl": "bregctl", @@ -208,15 +205,16 @@ def build( calls = [json.loads(line) for line in log.read_text().splitlines()] return result, output, calls - def test_all_mode_preserves_the_three_exact_ordered_cargo_invocations(self) -> None: + def test_all_mode_preserves_the_exact_ordered_cargo_invocations(self) -> None: result, output, calls = self.build("all") self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual([CORE_ARGS, BREG_ARGS, BREGCTL_ARGS], calls) + self.assertEqual([CORE_ARGS, BREG_ARGS, BREGCTL_ARGS, CASEWORK_RUNTIME_ARGS, CASEWORKCTL_ARGS], calls) self.assertEqual( [ *MODULE.rosters(VERSION)["core"], *MODULE.rosters(VERSION)["breg"], *MODULE.rosters(VERSION)["bregctl"], + *MODULE.rosters(VERSION)["casework"], ], [line.split(" ", 1)[1] for line in (output / "SHA256SUMS").read_text().splitlines()], ) @@ -303,6 +301,16 @@ def test_pre_breg_version_produces_and_merges_an_exact_empty_shard(self) -> None self.assertEqual("", (breg / "SHA256SUMS").read_text()) self.assertEqual("", (bregctl / "SHA256SUMS").read_text()) self.assertEqual("", (casework / "SHA256SUMS").read_text()) + # Historical recovery still requires the Mint bytes built by the old + # release source. Current builders omit them; supply immutable fixture + # bytes here so this tests the historical merger rather than rebuilding Mint. + legacy_mint = core / "platform" / f"mint-v{version}-macos-arm64" + legacy_mint.write_bytes(b"historical Mint fixture\n") + legacy_mint.chmod(0o755) + (core / "SHA256SUMS").write_text("".join( + f"{digest(core / 'platform' / name)} {name}\n" + for name in MODULE.rosters(version)["core"] + )) # Artifact upload/download does not retain empty directories. (breg / "platform").rmdir() (bregctl / "platform").rmdir() diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index 0d00f19a87..5403688e16 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -144,12 +144,11 @@ def test_builds_and_smokes_the_released_toolset_shape(self) -> None: for package in ( "registry-evidence", "registry-evidencectl", - "registry-mint", "registry-evidence-oid4vci", ): self.assertIn(f"-p {package}", build) self.assertIn("cargo build --release --locked", build) - self.assertIn("for binary in evidence evidencectl mint evidence-oid4vci", build) + self.assertIn("for binary in evidence evidencectl evidence-oid4vci", build) assemble = step_run( document, @@ -1774,7 +1773,6 @@ def test_public_verification_selects_the_versioned_image_roster(self) -> None: "casework", "discovery", "evidence", - "mint", "relay", ], } diff --git a/release/scripts/test_zig_glibc_compiler.py b/release/scripts/test_zig_glibc_compiler.py index 79bf07828e..fe16b45265 100644 --- a/release/scripts/test_zig_glibc_compiler.py +++ b/release/scripts/test_zig_glibc_compiler.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations +import hashlib import json import os import re @@ -224,7 +225,7 @@ def setUp(self) -> None: "target = pathlib.Path('target/release')\n" "target.mkdir(parents=True, exist_ok=True)\n" "for binary in ('registry-manifest', 'relay', 'relayctl', 'evidence', " - "'evidencectl', 'mint', 'evidence-oid4vci', 'discovery', 'breg', 'bregctl', " + "'evidencectl', 'evidence-oid4vci', 'discovery', 'breg', 'bregctl', " "'casework', 'caseworkctl'):\n" " (target / binary).write_text('fixture binary\\n')\n", encoding="utf-8", @@ -246,16 +247,17 @@ def setUp(self) -> None: "bin_dir = root / 'dist/bin'\n" "image_dir = root / 'dist/image-bin'\n" "parsed = tuple(int(part) for part in version.split('.'))\n" - "core = ['evidence', 'evidencectl', 'mint', 'evidence-oid4vci', " + "core = ['evidence', 'evidencectl', 'evidence-oid4vci', " "'registry-manifest', 'relay', 'relayctl']\n" "if parsed >= (0, 24, 0):\n" " core.insert(0, 'discovery')\n" "breg = ['breg', 'bregctl'] if parsed >= (0, 26, 0) else []\n" "selected = (core if group in ('all', 'core') else []) + " - "(breg if group in ('all', 'breg') else [])\n" + "(breg if group in ('all', 'breg') else []) + " + "(['casework', 'caseworkctl'] if parsed >= (0, 30, 0) and group in ('all', 'casework') else [])\n" "for name in selected:\n" " (bin_dir / f'{name}-{tag}-linux-amd64').write_text(name + '\\n')\n" - "for name in ('discovery', 'breg', 'evidence', 'mint', 'relay'):\n" + "for name in ('discovery', 'breg', 'casework', 'evidence', 'relay'):\n" " if name in selected:\n" " (image_dir / name).write_text(name + '\\n')\n", encoding="utf-8", @@ -366,8 +368,6 @@ def test_full_and_group_builds_keep_the_exact_cargo_partition(self) -> None: "-p", "registry-evidencectl", "-p", - "registry-mint", - "-p", "registry-evidence-oid4vci", ], [ @@ -522,6 +522,14 @@ def test_pre_breg_empty_producer_merges_with_the_core_shard(self) -> None: self.assertEqual( "", (shard_root / "breg/bin/SHA256SUMS").read_text(encoding="utf-8") ) + # Historical recovery retains the bytes produced by the old builder. + legacy_bin = shard_root / "core/bin" / "mint-v0.25.0-linux-amd64" + legacy_bin.write_bytes(b"historical Mint fixture\n") + legacy_bin.chmod(0o755) + sums = shard_root / "core/bin/SHA256SUMS" + lines = sums.read_text().splitlines(keepends=True) + lines.insert(3, f"{hashlib.sha256(legacy_bin.read_bytes()).hexdigest()} {legacy_bin.name}\n") + sums.write_text("".join(lines)) output = self.root / "merged" result = subprocess.run( [ @@ -550,7 +558,7 @@ def test_pre_breg_empty_producer_merges_with_the_core_shard(self) -> None: def test_merged_groups_are_byte_mode_and_inventory_equivalent_to_all(self) -> None: source_sha = "1" * 40 - def build(group: str, version: str = "0.27.0") -> subprocess.CompletedProcess[str]: + def build(group: str, version: str = "0.30.0") -> subprocess.CompletedProcess[str]: arguments = ["bash", str(self.scripts / BINARY_RECIPE.name)] if group != "all": arguments.extend(["--group", group]) @@ -576,7 +584,7 @@ def build(group: str, version: str = "0.27.0") -> subprocess.CompletedProcess[st shutil.copytree(self.root / "dist/image-bin", expected / "image-bin") shards = self.root / "shards" - for group in ("core", "breg"): + for group in ("core", "breg", "casework"): result = build(group) self.assertEqual(result.returncode, 0, result.stderr) destination = shards / group @@ -590,13 +598,15 @@ def build(group: str, version: str = "0.27.0") -> subprocess.CompletedProcess[st [ str(self.scripts / "merge-release-binary-shards.py"), "--version", - "0.27.0", + "0.30.0", "--source-sha", source_sha, "--core", str(shards / "core"), "--breg", str(shards / "breg"), + "--casework", + str(shards / "casework"), "--output", str(output), "--builder-image", From 0f6855498aaccc93cbec8794281ac6d1320e74e0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:17:45 +0700 Subject: [PATCH 022/120] chore(mint): remove retired issuer Signed-off-by: Jeremi Joslin --- Cargo.lock | 41 - Cargo.toml | 2 - crates/registry-cli-docs/Cargo.toml | 1 - crates/registry-cli-docs/src/lib.rs | 15 - crates/registry-evidencectl/src/doctor.rs | 255 +-- crates/registry-evidencectl/src/lib.rs | 4 - crates/registry-evidencectl/src/runtime.rs | 4 - crates/registry-evidencectl/tests/doctor.rs | 645 +------ .../tests/production_handoff.rs | 315 +--- .../tests/support/production_handoff_https.py | 42 - crates/registry-mint/Cargo.toml | 58 - crates/registry-mint/README.md | 563 ------ crates/registry-mint/benches/replay_cache.rs | 106 -- crates/registry-mint/demo/.gitignore | 3 - crates/registry-mint/demo/README.md | 278 --- .../adapters/demo-source-prepare.rhai | 16 - .../evidence-bundle/adapters/demo-source.rhai | 13 - .../evidence-bundle/codelists/region-map.yaml | 6 - .../derivations/residence-region.rhai | 10 - .../demo/evidence-bundle/evidence.yaml | 109 -- .../demo/evidence-bundle/fixtures/cases.yaml | 31 - ...xmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json | 1 - .../schemas/adapter-parameters.schema.yaml | 7 - .../evidence-bundle/schemas/facts.schema.yaml | 5 - .../schemas/response.schema.yaml | 6 - crates/registry-mint/demo/run.sh | 74 - .../demo/support/key_material.py | 132 -- .../registry-mint/demo/support/mock_source.py | 65 - .../registry-mint/demo/support/provision.py | 240 --- .../demo/support/test_provision.py | 108 -- .../demo/support/test_tls_front.py | 225 --- .../registry-mint/demo/support/tls_front.py | 139 -- crates/registry-mint/demo/walkthrough.py | 311 ---- crates/registry-mint/src/assertion.rs | 1164 ------------- crates/registry-mint/src/audit.rs | 431 ----- crates/registry-mint/src/caller.rs | 396 ----- crates/registry-mint/src/cli.rs | 293 ---- crates/registry-mint/src/client_secret.rs | 108 -- crates/registry-mint/src/clients.rs | 1507 ---------------- crates/registry-mint/src/config.rs | 1477 ---------------- crates/registry-mint/src/error.rs | 156 -- crates/registry-mint/src/lib.rs | 75 - crates/registry-mint/src/main.rs | 368 ---- crates/registry-mint/src/replay.rs | 265 --- crates/registry-mint/src/secretfile.rs | 223 --- crates/registry-mint/src/server.rs | 1550 ----------------- crates/registry-mint/src/token.rs | 1368 --------------- .../registry-mint/tests/client_secret_cli.rs | 54 - .../tests/client_secret_compatibility.rs | 314 ---- .../tests/delegated_subject_binding.rs | 685 -------- .../tests/evidence_compatibility.rs | 676 ------- crates/registry-mint/tests/token_cli.rs | 700 -------- crates/registry-mint/tests/version_cli.rs | 36 - docs/site/src/data/cli-reference.yaml | 6 +- 54 files changed, 29 insertions(+), 15653 deletions(-) delete mode 100644 crates/registry-mint/Cargo.toml delete mode 100644 crates/registry-mint/README.md delete mode 100644 crates/registry-mint/benches/replay_cache.rs delete mode 100644 crates/registry-mint/demo/.gitignore delete mode 100644 crates/registry-mint/demo/README.md delete mode 100644 crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai delete mode 100644 crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai delete mode 100644 crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml delete mode 100644 crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai delete mode 100644 crates/registry-mint/demo/evidence-bundle/evidence.yaml delete mode 100644 crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml delete mode 100644 crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json delete mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml delete mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml delete mode 100644 crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml delete mode 100755 crates/registry-mint/demo/run.sh delete mode 100755 crates/registry-mint/demo/support/key_material.py delete mode 100644 crates/registry-mint/demo/support/mock_source.py delete mode 100644 crates/registry-mint/demo/support/provision.py delete mode 100644 crates/registry-mint/demo/support/test_provision.py delete mode 100644 crates/registry-mint/demo/support/test_tls_front.py delete mode 100644 crates/registry-mint/demo/support/tls_front.py delete mode 100644 crates/registry-mint/demo/walkthrough.py delete mode 100644 crates/registry-mint/src/assertion.rs delete mode 100644 crates/registry-mint/src/audit.rs delete mode 100644 crates/registry-mint/src/caller.rs delete mode 100644 crates/registry-mint/src/cli.rs delete mode 100644 crates/registry-mint/src/client_secret.rs delete mode 100644 crates/registry-mint/src/clients.rs delete mode 100644 crates/registry-mint/src/config.rs delete mode 100644 crates/registry-mint/src/error.rs delete mode 100644 crates/registry-mint/src/lib.rs delete mode 100644 crates/registry-mint/src/main.rs delete mode 100644 crates/registry-mint/src/replay.rs delete mode 100644 crates/registry-mint/src/secretfile.rs delete mode 100644 crates/registry-mint/src/server.rs delete mode 100644 crates/registry-mint/src/token.rs delete mode 100644 crates/registry-mint/tests/client_secret_cli.rs delete mode 100644 crates/registry-mint/tests/client_secret_compatibility.rs delete mode 100644 crates/registry-mint/tests/delegated_subject_binding.rs delete mode 100644 crates/registry-mint/tests/evidence_compatibility.rs delete mode 100644 crates/registry-mint/tests/token_cli.rs delete mode 100644 crates/registry-mint/tests/version_cli.rs diff --git a/Cargo.lock b/Cargo.lock index a475a6a383..6fc3621259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3691,7 +3691,6 @@ dependencies = [ "registry-evidence", "registry-evidence-oid4vci", "registry-evidencectl", - "registry-mint", "registry-relay-v2", "registry-relayctl", "serde", @@ -4118,46 +4117,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "registry-mint" -version = "0.30.0" -dependencies = [ - "async-trait", - "axum", - "axum-test", - "base64", - "clap", - "ed25519-dalek", - "getrandom 0.4.3", - "http", - "jsonwebtoken", - "p256", - "registry-evidence", - "registry-platform-audit", - "registry-platform-authcommon", - "registry-platform-buildinfo", - "registry-platform-canonical-json", - "registry-platform-config", - "registry-platform-crypto", - "registry-platform-httputil", - "registry-platform-oidc", - "reqwest", - "rustix 1.1.4", - "serde", - "serde_json", - "serde_norway", - "tempfile", - "thiserror 2.0.20", - "time", - "tokio", - "tower-http 0.7.0", - "tracing", - "tracing-subscriber", - "ulid", - "url", - "zeroize", -] - [[package]] name = "registry-platform-audit" version = "0.30.0" diff --git a/Cargo.toml b/Cargo.toml index ed281b5da9..c37ec6e5ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ members = [ "crates/registry-thunderid-tooling", "crates/registry-manifest-core", "crates/registry-manifest-cli", - "crates/registry-mint", "crates/registry-record", "crates/registry-breg-client", "crates/registry-breg-client-node", @@ -97,7 +96,6 @@ registry-evidencectl = { path = "crates/registry-evidencectl", version = "0.30.0 registry-language-server = { path = "crates/registry-language-server", version = "0.30.0" } registry-linkml = { path = "crates/registry-linkml", version = "0.30.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.30.0" } -registry-mint = { path = "crates/registry-mint", version = "0.30.0" } registry-record = { path = "crates/registry-record", version = "0.30.0" } registry-breg-client = { path = "crates/registry-breg-client", version = "0.30.0" } registry-breg-client-node = { path = "crates/registry-breg-client-node", version = "0.30.0" } diff --git a/crates/registry-cli-docs/Cargo.toml b/crates/registry-cli-docs/Cargo.toml index 3e93612f4d..8ee0b44e0b 100644 --- a/crates/registry-cli-docs/Cargo.toml +++ b/crates/registry-cli-docs/Cargo.toml @@ -16,7 +16,6 @@ clap.workspace = true registry-evidence.workspace = true registry-evidence-oid4vci.workspace = true registry-evidencectl.workspace = true -registry-mint.workspace = true registry-relay-v2.workspace = true registry-relayctl.workspace = true registry-breg = { workspace = true, features = ["runtime"] } diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index acc98e0d70..887cb1a067 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -84,7 +84,6 @@ pub fn catalog() -> Catalog { command_reference(registry_evidence::command(), None, None), command_reference(registry_evidence_oid4vci::command(), None, None), command_reference(registry_evidencectl::command(), None, None), - command_reference(registry_mint::command(), None, None), command_reference(registry_caseworkctl::command(), None, None), command_reference(registry_casework::command(), None, None), command_reference(registry_breg::command(), None, None), @@ -617,7 +616,6 @@ mod tests { "evidence", "evidence-oid4vci", "evidencectl", - "mint", "relay", "relayctl", ] @@ -1195,18 +1193,6 @@ mod tests { && constraint.arguments == ["--subject ", "--subjects-file "] })); - let mint_token = find_command(&catalog.binaries, "mint token"); - for (when, required) in [ - ("--actor ", "--subject-file "), - ("--subject-file ", "--actor "), - ] { - assert!(mint_token.constraints.iter().any(|constraint| { - constraint.kind == ConstraintKind::RequiresAll - && constraint.when.as_deref() == Some(when) - && constraint.arguments == [required] - })); - } - let dev = find_command(&catalog.binaries, "evidencectl dev"); assert_eq!( dev.usage, @@ -1223,7 +1209,6 @@ mod tests { })); for (invocation, option) in [ - ("mint check", "--config "), ("evidence-oid4vci check", "--config "), ("relay serve", "--runtime "), ] { diff --git a/crates/registry-evidencectl/src/doctor.rs b/crates/registry-evidencectl/src/doctor.rs index 80000d19ec..57d36759f2 100644 --- a/crates/registry-evidencectl/src/doctor.rs +++ b/crates/registry-evidencectl/src/doctor.rs @@ -17,11 +17,6 @@ //! user the service runs as. And a project on a read-only mount satisfies the //! immutability rule whatever its modes say, which this mirrors. //! -//! An explicitly paired Mint configuration adds a separate, mechanical check -//! of the access-token fields both products share. It does not discover Mint, -//! validate either product, read a client registry or key, or make an -//! authorization decision. The product `check` commands remain authoritative. -//! //! The acquisition check is the same kind of restatement over the two halves of //! the acquisition gate: the bundle names the acquisition kinds it needs, the //! runtime file names the kinds this deployment may serve, and Evidence refuses @@ -34,7 +29,6 @@ //! acquires nothing and holds none. use std::{ - collections::BTreeSet, fs::{self, Metadata}, os::unix::fs::{MetadataExt as _, PermissionsExt as _}, path::{Path, PathBuf}, @@ -49,12 +43,6 @@ use serde_norway::Value as YamlValue; /// How a bundle names a secret the file provider resolves. const SECRET_REFERENCE_PREFIX: &str = "secret:file/"; -/// The JWT `typ` Registry Mint writes on access tokens. -const MINT_ACCESS_TOKEN_TYPE: &str = "at+jwt"; - -/// Registry Mint's default public-key route when `signing.jwksPath` is omitted. -const DEFAULT_MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; - /// The acquisition capabilities an operator must enable in `runtime.yaml` /// before a deployment uses them. /// @@ -76,11 +64,6 @@ pub struct DoctorArgs { #[arg(long, default_value = ".")] pub project: PathBuf, - /// Mechanically compare this Registry Mint configuration with Evidence - /// Gateway authentication. - #[arg(long, value_name = "PATH")] - pub mint_config: Option, - /// Emit one machine-readable JSON report on standard output. #[arg(long)] pub json: bool, @@ -165,15 +148,6 @@ pub fn run(args: DoctorArgs) -> Result { &bundle_config_path, ); checks.push(acquisition); - if let Some(mint_config_path) = args.mint_config.as_deref() { - checks.push(check_mint_compatibility( - project, - &bundle_config_path, - &bundle, - mint_config_path, - )); - } - let passed = checks.iter().all(|check| check.passed); let inspected = checks.iter().map(|check| check.inspected).sum(); let report = DoctorReport { @@ -432,8 +406,8 @@ fn check_audit(project: &Path, runtime: &YamlValue, runtime_path: &Path) -> Chec /// One requirement's declared acquisition, projected far enough to say what the /// deployment will call and what it must be allowed to call. /// -/// Like the paired-Mint projection, this reads the fields it needs and leaves -/// the rest of the bundle to `evidence check`. It is deliberately not closed +/// This reads the fields it needs and leaves the rest of the bundle to +/// `evidence check`. It is deliberately not closed /// against unknown members: a bundle written for a later runtime must still /// render here rather than be reported as broken by adopter tooling. #[derive(Debug, Deserialize)] @@ -725,231 +699,6 @@ fn capability_list(value: &YamlValue) -> Option> { .collect() } -/// The Evidence fields whose values must agree with a paired Mint deployment. -/// -/// This deliberately projects only the protocol binding. The rest of the -/// bundle is governed by `evidence check`, and accepting it here would turn -/// adopter tooling into a second implementation of Evidence configuration. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EvidenceAuthenticationCompatibility { - issuer: String, - audiences: Vec, - token_types: Vec, - algorithms: Vec, - jwks_uri: String, - principal_claim: String, - requester_tags_claim: String, - evidence_audience_claim: String, - grant_id_claim: String, - grant_authority_claim: String, - actor_claim: Option, -} - -/// The corresponding Mint projection. Mint's own `mint check` owns every -/// other field, including key files, clients and client assertions. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MintCompatibilityDocument { - issuer: String, - signing: MintSigningCompatibility, - access_tokens: MintAccessTokenCompatibility, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MintSigningCompatibility { - algorithm: String, - #[serde(default = "default_mint_jwks_path")] - jwks_path: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MintAccessTokenCompatibility { - audiences: Vec, - claims: MintClaimCompatibility, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MintClaimCompatibility { - #[serde(default = "default_principal_claim")] - principal: String, - requester_tags: String, - evidence_audience: String, - grant_id: String, - grant_authority: String, - actor: Option, -} - -fn default_mint_jwks_path() -> String { - DEFAULT_MINT_JWKS_PATH.to_owned() -} - -fn default_principal_claim() -> String { - "sub".to_owned() -} - -fn check_mint_compatibility( - project: &Path, - bundle_config_path: &Path, - bundle: &YamlValue, - mint_config_path: &Path, -) -> Check { - let mut run = CheckRun::new("mint compatibility", project); - run.inspected += 1; - let evidence: Option = bundle - .get("authentication") - .cloned() - .and_then(|authentication| serde_norway::from_value(authentication).ok()); - if evidence.is_none() { - run.refuse( - bundle_config_path, - "authentication paired-Mint compatibility fields are missing or invalid".to_owned(), - ); - } - let mint = read_mint_compatibility(&mut run, mint_config_path); - - let (Some(evidence), Some(mint)) = (evidence, mint) else { - return run.finish(); - }; - - if evidence.issuer != mint.issuer { - run.refuse( - bundle_config_path, - "authentication.issuer does not match the paired Mint issuer".to_owned(), - ); - } - - // This is the route Mint publishes in its metadata. It is concatenation, - // not URL joining: path-bearing issuers are part of Mint's contract. - let mint_jwks_uri = format!("{}{}", mint.issuer, mint.signing.jwks_path); - if evidence.jwks_uri != mint_jwks_uri { - run.refuse( - bundle_config_path, - "authentication.jwksUri does not match the paired Mint JWKS endpoint".to_owned(), - ); - } - - if !same_string_set(&evidence.audiences, &mint.access_tokens.audiences) { - run.refuse( - bundle_config_path, - "authentication.audiences do not match the paired Mint access-token audiences" - .to_owned(), - ); - } - if !evidence.algorithms.contains(&mint.signing.algorithm) { - run.refuse( - bundle_config_path, - "authentication.algorithms does not admit the paired Mint access-token signing algorithm" - .to_owned(), - ); - } - if !evidence - .token_types - .iter() - .any(|token_type| token_type == MINT_ACCESS_TOKEN_TYPE) - { - run.refuse( - bundle_config_path, - "authentication.tokenTypes does not admit Mint at+jwt access tokens".to_owned(), - ); - } - - compare_claim_name( - &mut run, - bundle_config_path, - "principalClaim", - &evidence.principal_claim, - &mint.access_tokens.claims.principal, - ); - compare_claim_name( - &mut run, - bundle_config_path, - "requesterTagsClaim", - &evidence.requester_tags_claim, - &mint.access_tokens.claims.requester_tags, - ); - compare_claim_name( - &mut run, - bundle_config_path, - "evidenceAudienceClaim", - &evidence.evidence_audience_claim, - &mint.access_tokens.claims.evidence_audience, - ); - compare_claim_name( - &mut run, - bundle_config_path, - "grantIdClaim", - &evidence.grant_id_claim, - &mint.access_tokens.claims.grant_id, - ); - compare_claim_name( - &mut run, - bundle_config_path, - "grantAuthorityClaim", - &evidence.grant_authority_claim, - &mint.access_tokens.claims.grant_authority, - ); - if evidence.actor_claim != mint.access_tokens.claims.actor { - run.refuse( - bundle_config_path, - "authentication.actorClaim does not match accessTokens.claims.actor".to_owned(), - ); - } - - run.finish() -} - -fn read_mint_compatibility( - run: &mut CheckRun<'_>, - mint_config_path: &Path, -) -> Option { - run.inspected += 1; - let bytes = match fs::read(mint_config_path) { - Ok(bytes) => bytes, - Err(_) => { - run.refuse( - mint_config_path, - "paired Mint configuration cannot be read".to_owned(), - ); - return None; - } - }; - match serde_norway::from_slice(&bytes) { - Ok(document) => Some(document), - Err(_) => { - run.refuse( - mint_config_path, - "paired Mint compatibility fields are missing or invalid".to_owned(), - ); - None - } - } -} - -fn same_string_set(left: &[String], right: &[String]) -> bool { - left.iter().collect::>() == right.iter().collect::>() -} - -fn compare_claim_name( - run: &mut CheckRun<'_>, - bundle_config_path: &Path, - evidence_field: &str, - evidence_claim: &str, - mint_claim: &str, -) { - if evidence_claim != mint_claim { - run.refuse( - bundle_config_path, - format!( - "authentication.{evidence_field} does not match its paired Mint access-token claim name" - ), - ); - } -} - /// One check under construction: the artifacts it looked at, and the reasons it /// refused any of them. struct CheckRun<'a> { diff --git a/crates/registry-evidencectl/src/lib.rs b/crates/registry-evidencectl/src/lib.rs index 68177c65e4..fc347fa8ca 100644 --- a/crates/registry-evidencectl/src/lib.rs +++ b/crates/registry-evidencectl/src/lib.rs @@ -205,9 +205,6 @@ impl std::error::Error for SafeCliFailure {} struct ArtifactInspectArgs { /// Deployment project containing runtime.yaml beside bundle/. project: PathBuf, - /// Mechanically compare this Registry Mint configuration with Evidence authentication. - #[arg(long)] - mint_config: Option, } /// Return the complete command tree without running Evidence adopter tooling. @@ -336,7 +333,6 @@ pub fn main_entry() -> ExitCode { ), Command::Artifact(ArtifactCommand::Inspect(args)) => doctor::run(doctor::DoctorArgs { project: args.project, - mint_config: args.mint_config, json: format == OutputFormat::Json, }), Command::Dev(args) => safe_dev_command(dev::run_with_format(args, format)), diff --git a/crates/registry-evidencectl/src/runtime.rs b/crates/registry-evidencectl/src/runtime.rs index 6e978a5cdb..bea7745b34 100644 --- a/crates/registry-evidencectl/src/runtime.rs +++ b/crates/registry-evidencectl/src/runtime.rs @@ -52,9 +52,6 @@ pub(crate) struct DoctorArgs { /// runtime.yaml beside bundle/. New dependency checks use --runtime-config. #[arg(long, conflicts_with = "runtime_config")] project: Option, - /// Compatibility Mint configuration for the former artifact inspection. - #[arg(long, value_name = "PATH", conflicts_with = "runtime_config")] - mint_config: Option, /// Compatibility spelling for JSON artifact-inspection output. #[arg(long, conflicts_with_all = ["runtime_config", "output_format"])] json: bool, @@ -86,7 +83,6 @@ pub(crate) fn run(args: DoctorArgs, format: OutputFormat) -> Result { let project = args.project.unwrap_or_else(|| PathBuf::from(".")); return crate::doctor::run(crate::doctor::DoctorArgs { project, - mint_config: args.mint_config, json: args.json || format == OutputFormat::Json, }); } diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs index 797aa0406a..72fbbac1aa 100644 --- a/crates/registry-evidencectl/tests/doctor.rs +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -47,21 +47,6 @@ const DECLARED_ACQUISITIONS: &str = r#"requirements: parameters: {survivorship_policy: s3cr3t-canary-value} "#; -const MATCHING_MINT_CONFIG: &str = r#"version: 1 -issuer: https://identity.invalid -signing: - algorithm: ES256 - jwksPath: /.well-known/jwks.json -accessTokens: - audiences: [evidence-scaffold] - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -"#; - #[test] fn doctor_passes_a_frozen_project_and_leaves_the_public_key_beside_it_alone() { let workspace = tempfile::tempdir().expect("tempdir"); @@ -238,570 +223,32 @@ fn doctor_json_puts_one_document_on_stdout_and_the_report_on_stderr() { } #[test] -fn doctor_checks_only_an_explicit_external_mint_config() { - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let external_mint = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - write_mint(&external_mint, MATCHING_MINT_CONFIG); - - // A nested document must not be discovered. The explicit path is the only - // act that pairs Evidence with Mint. - write_mint( - &project.join("mint/mint.yaml"), - &MATCHING_MINT_CONFIG.replace("https://identity.invalid", "https://nested.invalid"), - ); - - freeze(&project); - let unpaired = doctor(&project, &[]); - let paired = doctor( - &project, - &[ - "--mint-config", - external_mint.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - assert!( - unpaired.status.success(), - "doctor discovered an unrequested Mint config:\n{}{}", - stdout_of(&unpaired), - stderr_of(&unpaired) - ); - assert!( - !stdout_of(&unpaired).contains("mint compatibility"), - "unpaired doctor reported a Mint check: {}", - stdout_of(&unpaired) - ); - assert!( - paired.status.success(), - "doctor rejected matching external Mint config:\n{}{}", - stdout_of(&paired), - stderr_of(&paired) - ); - assert!( - stdout_of(&paired).contains("PASS: mint compatibility"), - "paired doctor omitted its compatibility result: {}", - stdout_of(&paired) - ); -} - -#[test] -fn doctor_rejects_an_issuer_mismatch_without_printing_its_value() { - const SENTINEL: &str = "https://credential-token-selector-source.invalid"; - - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - write_mint( - &mint_config, - &MATCHING_MINT_CONFIG.replace("https://identity.invalid", SENTINEL), - ); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); - assert!( - !output.status.success(), - "doctor accepted an issuer mismatch: {diagnostics}" - ); - assert!( - diagnostics.contains("authentication.issuer"), - "issuer mismatch did not identify its field: {diagnostics}" - ); - assert!( - !diagnostics.contains(SENTINEL), - "issuer mismatch disclosed the configured value: {diagnostics}" - ); -} - -#[test] -fn doctor_reports_every_mint_field_mismatch_without_printing_values() { - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - - let cases = [ - ( - "jwksPath: /.well-known/jwks.json", - "jwksPath: /credential-token-selector-source-jwks", - "authentication.jwksUri", - "/credential-token-selector-source-jwks", - ), - ( - "audiences: [evidence-scaffold]", - "audiences: [credential-token-selector-source-audience]", - "authentication.audiences", - "credential-token-selector-source-audience", - ), - ( - "algorithm: ES256", - "algorithm: RS256", - "authentication.algorithms", - "RS256", - ), - ( - "principal: sub", - "principal: credential_token_selector_source_principal", - "authentication.principalClaim", - "credential_token_selector_source_principal", - ), - ( - "requesterTags: evidence_tags", - "requesterTags: credential_token_selector_source_tags", - "authentication.requesterTagsClaim", - "credential_token_selector_source_tags", - ), - ( - "evidenceAudience: evidence_audience", - "evidenceAudience: credential_token_selector_source_audience_claim", - "authentication.evidenceAudienceClaim", - "credential_token_selector_source_audience_claim", - ), - ( - "grantId: evidence_grant_id", - "grantId: credential_token_selector_source_grant_id", - "authentication.grantIdClaim", - "credential_token_selector_source_grant_id", - ), - ( - "grantAuthority: evidence_authority", - "grantAuthority: credential_token_selector_source_authority", - "authentication.grantAuthorityClaim", - "credential_token_selector_source_authority", - ), - ]; - - for (original, replacement, field, sentinel) in cases { - let mismatched = replace_once(MATCHING_MINT_CONFIG, original, replacement); - write_mint(&mint_config, &mismatched); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); - assert!( - !output.status.success(), - "doctor accepted mismatch in {field}: {diagnostics}" - ); - assert!( - diagnostics.contains(field), - "mismatch did not identify {field}: {diagnostics}" - ); - assert!( - !diagnostics.contains(sentinel), - "mismatch in {field} disclosed its configured value: {diagnostics}" - ); - } -} - -#[test] -fn doctor_requires_evidence_to_admit_the_mint_access_token_type() { - const SENTINEL: &str = "credential-token-selector-source-type"; - - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - rewrite_bundle( - &project, - "tokenTypes: [at+jwt]", - &format!("tokenTypes: [{SENTINEL}]"), - ); - write_mint(&mint_config, MATCHING_MINT_CONFIG); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); - assert!(!output.status.success(), "doctor accepted {diagnostics}"); - assert!( - diagnostics.contains("authentication.tokenTypes"), - "token-type mismatch did not identify its field: {diagnostics}" - ); - assert!( - !diagnostics.contains(SENTINEL), - "token-type mismatch disclosed its configured value: {diagnostics}" - ); -} - -#[test] -fn doctor_checks_every_actor_claim_presence_combination() { - let workspace = tempfile::tempdir().expect("tempdir"); - let mint_config = workspace.path().join("mint/mint.yaml"); - let cases = [ - (None, None, true), - (Some("shared_actor"), Some("shared_actor"), true), - (Some("evidence_actor"), None, false), - (None, Some("mint_actor"), false), - (Some("evidence_actor"), Some("mint_actor"), false), - ]; - - for (index, (evidence_actor, mint_actor, expected_pass)) in cases.into_iter().enumerate() { - let project = workspace.path().join(format!("project-{index}")); - provision(&project); - provision_bearer_token(&project); - if let Some(actor) = evidence_actor { - add_evidence_actor(&project, actor); - } - let mut mint = MATCHING_MINT_CONFIG.to_owned(); - if let Some(actor) = mint_actor { - mint.push_str(&format!(" actor: {actor}\n")); - } - write_mint(&mint_config, &mint); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); - assert_eq!( - output.status.success(), - expected_pass, - "unexpected actor compatibility result: {diagnostics}" - ); - if !expected_pass { - assert!( - diagnostics.contains("authentication.actorClaim"), - "actor mismatch did not identify its field: {diagnostics}" - ); - for value in [evidence_actor, mint_actor].into_iter().flatten() { - assert!( - !diagnostics.contains(value), - "actor mismatch disclosed its configured value: {diagnostics}" - ); - } - } - } -} - -#[test] -fn doctor_accepts_set_order_supersets_custom_jwks_and_matching_actor() { - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - - rewrite_bundle( - &project, - "issuer: https://identity.invalid", - "issuer: https://identity.invalid/", - ); - rewrite_bundle( - &project, - "audiences: [evidence-scaffold]", - "audiences: [secondary-audience, evidence-scaffold]", - ); - rewrite_bundle( - &project, - "tokenTypes: [at+jwt]", - "tokenTypes: [application/at+jwt, at+jwt]", - ); - rewrite_bundle( - &project, - "algorithms: [ES256]", - "algorithms: [RS256, ES256]", - ); - rewrite_bundle( - &project, - "jwksUri: https://identity.invalid/.well-known/jwks.json", - "jwksUri: https://identity.invalid//custom/jwks.json", - ); - add_evidence_actor(&project, "shared_actor"); - - let mut mint = MATCHING_MINT_CONFIG - .replace( - "issuer: https://identity.invalid", - "issuer: https://identity.invalid/", - ) - .replace( - "jwksPath: /.well-known/jwks.json", - "jwksPath: /custom/jwks.json", - ) - .replace( - "audiences: [evidence-scaffold]", - "audiences: [evidence-scaffold, secondary-audience]", - ); - mint.push_str(" actor: shared_actor\n"); - write_mint(&mint_config, &mint); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - assert!( - output.status.success(), - "doctor rejected mechanically compatible sets and supersets:\n{}{}", - stdout_of(&output), - stderr_of(&output) - ); -} - -#[test] -fn doctor_applies_mint_protocol_defaults() { - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - let mint = MATCHING_MINT_CONFIG - .replace(" jwksPath: /.well-known/jwks.json\n", "") - .replace(" principal: sub\n", ""); - write_mint(&mint_config, &mint); - - freeze(&project); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - - assert!( - output.status.success(), - "doctor did not apply Mint's JWKS and principal defaults:\n{}{}", - stdout_of(&output), - stderr_of(&output) - ); -} - -#[test] -fn doctor_json_aggregates_mismatches_and_redacts_every_value() { - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - - let sentinels = [ - "credential-token-selector-source-audience", - "RS256", - "credential_token_selector_source_principal", - ]; - let mint = MATCHING_MINT_CONFIG - .replace( - "audiences: [evidence-scaffold]", - &format!("audiences: [{}]", sentinels[0]), - ) - .replace("algorithm: ES256", &format!("algorithm: {}", sentinels[1])) - .replace("principal: sub", &format!("principal: {}", sentinels[2])); - write_mint(&mint_config, &mint); - - freeze(&project); - let output = doctor( - &project, - &[ +fn retired_mint_compatibility_option_is_not_accepted() { + for arguments in [ + vec!["doctor", "--mint-config", "retired-mint.yaml"], + vec![ + "artifact", + "inspect", + "deployment", "--mint-config", - mint_config.to_str().expect("Mint config path"), - "--json", + "retired-mint.yaml", ], - ); - unfreeze(&project); - - assert!(!output.status.success(), "doctor accepted three mismatches"); - let stdout = stdout_of(&output); - let report: serde_json::Value = serde_json::from_str(stdout.trim()).expect("doctor JSON"); - let check = report["checks"] - .as_array() - .expect("checks") - .iter() - .find(|check| check["name"] == "mint compatibility") - .expect("Mint compatibility check"); - assert_eq!( - check["findings"].as_array().expect("findings").len(), - 3, - "doctor must report every mechanical mismatch in one run: {stdout}" - ); - - let diagnostics = format!("{stdout}{}", stderr_of(&output)); - for field in [ - "authentication.audiences", - "authentication.algorithms", - "authentication.principalClaim", ] { + let output = evidencectl(&arguments); + assert_eq!(output.status.code(), Some(2)); + let stderr = stderr_of(&output); assert!( - diagnostics.contains(field), - "aggregate diagnostics omitted {field}: {diagnostics}" + stderr.contains("error[evidencectl.usage]") + && stderr.contains("unsupported arguments"), + "retired Mint compatibility option was not refused by the parser: {stderr}" ); - } - for sentinel in sentinels { assert!( - !diagnostics.contains(sentinel), - "JSON or human diagnostics disclosed {sentinel}: {diagnostics}" + !stderr.contains("retired-mint.yaml"), + "usage refusal disclosed the retired configuration path: {stderr}" ); } } -#[test] -fn doctor_redacts_invalid_paired_documents() { - const SENTINEL: &str = "credential-token-selector-source-invalid-document"; - - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_config = workspace.path().join("mint/mint.yaml"); - provision(&project); - provision_bearer_token(&project); - write_mint(&mint_config, &format!("issuer: {SENTINEL}\nsigning: [\n")); - - freeze(&project); - let invalid_mint = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - let mint_diagnostics = format!("{}{}", stdout_of(&invalid_mint), stderr_of(&invalid_mint)); - assert!(!invalid_mint.status.success()); - assert!( - mint_diagnostics.contains("paired Mint compatibility fields are missing or invalid"), - "invalid Mint document lacked a stable diagnostic: {mint_diagnostics}" - ); - assert!( - !mint_diagnostics.contains(SENTINEL), - "Mint decoder error disclosed an authored value: {mint_diagnostics}" - ); - - write_mint(&mint_config, MATCHING_MINT_CONFIG); - rewrite_bundle( - &project, - "issuer: https://identity.invalid", - &format!("issuer: [{SENTINEL}]"), - ); - freeze(&project); - let invalid_evidence = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - unfreeze(&project); - let evidence_diagnostics = format!( - "{}{}", - stdout_of(&invalid_evidence), - stderr_of(&invalid_evidence) - ); - assert!(!invalid_evidence.status.success()); - assert!( - evidence_diagnostics - .contains("authentication paired-Mint compatibility fields are missing or invalid"), - "invalid Evidence binding lacked a stable diagnostic: {evidence_diagnostics}" - ); - assert!( - !evidence_diagnostics.contains(SENTINEL), - "Evidence decoder error disclosed an authored value: {evidence_diagnostics}" - ); -} - -#[test] -fn doctor_pairing_is_read_only_and_does_not_inspect_mint_authority_material() { - const SENTINEL: &str = "credential-token-selector-source-authority-material"; - - let workspace = tempfile::tempdir().expect("tempdir"); - let project = workspace.path().join("project"); - let mint_root = workspace.path().join("mint"); - let mint_config = mint_root.join("mint.yaml"); - provision(&project); - provision_bearer_token(&project); - let mut mint = MATCHING_MINT_CONFIG.replace( - " jwksPath: /.well-known/jwks.json", - &format!( - " activePublicJwkFile: public-keys/{SENTINEL}.jwk.json\n jwksPath: /.well-known/jwks.json" - ), - ); - mint.push_str("clients:\n directory: clients\n"); - write_mint(&mint_config, &mint); - fs::create_dir_all(mint_root.join("secrets")).expect("Mint secrets"); - fs::create_dir_all(mint_root.join("clients")).expect("Mint clients"); - fs::write(mint_root.join("secrets").join(SENTINEL), SENTINEL).expect("private key sentinel"); - fs::write(mint_root.join("clients/client.yaml"), SENTINEL).expect("client sentinel"); - - freeze(&project); - let before = tree_snapshot(workspace.path()); - let output = doctor( - &project, - &[ - "--mint-config", - mint_config.to_str().expect("Mint config path"), - ], - ); - let after = tree_snapshot(workspace.path()); - unfreeze(&project); - - assert!( - output.status.success(), - "doctor inspected unrelated Mint authority material:\n{}{}", - stdout_of(&output), - stderr_of(&output) - ); - assert!( - before == after, - "doctor changed a deployment artifact; snapshot contents are withheld" - ); - let diagnostics = format!("{}{}", stdout_of(&output), stderr_of(&output)); - assert!( - !diagnostics.contains(SENTINEL), - "doctor disclosed Mint authority material: {diagnostics}" - ); -} - -#[test] -fn doctor_help_exposes_the_explicit_mint_config_option() { - let output = evidencectl(&["doctor", "--help"]); - assert!(output.status.success(), "doctor --help failed"); - assert!( - stdout_of(&output).contains("--mint-config "), - "doctor help omitted --mint-config: {}", - stdout_of(&output) - ); -} - /// A bundle declaring a gated acquisition kind states what it needs; the /// deployment that will serve it decides separately, in a file the bundle /// author does not write. Evidence refuses the pair with a value-free sentence @@ -1190,66 +637,6 @@ fn enable_acquisition_capability_twice(project: &Path) { fs::write(&path, document).expect("enable the acquisition capability twice"); } -fn write_mint(path: &Path, document: &str) { - fs::create_dir_all(path.parent().expect("Mint parent")).expect("Mint directory"); - fs::write(path, document).expect("Mint configuration"); -} - -fn rewrite_bundle(project: &Path, original: &str, replacement: &str) { - let path = project.join("bundle/evidence.yaml"); - let document = fs::read_to_string(&path).expect("Evidence configuration"); - fs::write(&path, replace_once(&document, original, replacement)) - .expect("rewrite Evidence configuration"); -} - -fn add_evidence_actor(project: &Path, actor: &str) { - rewrite_bundle( - project, - " grantAuthorityClaim: evidence_authority", - &format!(" grantAuthorityClaim: evidence_authority\n actorClaim: {actor}"), - ); -} - -fn replace_once(document: &str, original: &str, replacement: &str) -> String { - assert_eq!( - document.matches(original).count(), - 1, - "fixture must contain exactly one {original:?}" - ); - document.replacen(original, replacement, 1) -} - -fn tree_snapshot(root: &Path) -> Vec<(String, u32, Vec)> { - let mut snapshot = Vec::new(); - collect_tree_snapshot(root, root, &mut snapshot); - snapshot -} - -fn collect_tree_snapshot(root: &Path, path: &Path, snapshot: &mut Vec<(String, u32, Vec)>) { - let metadata = fs::symlink_metadata(path).expect("snapshot metadata"); - let relative = path - .strip_prefix(root) - .expect("snapshot root") - .display() - .to_string(); - let mode = metadata.permissions().mode() & 0o7777; - if metadata.is_dir() { - snapshot.push((relative, mode, Vec::new())); - let mut entries: Vec<_> = fs::read_dir(path) - .expect("snapshot directory") - .map(|entry| entry.expect("snapshot entry").path()) - .collect(); - entries.sort(); - for entry in entries { - collect_tree_snapshot(root, &entry, snapshot); - } - } else { - snapshot.push((relative, mode, fs::read(path).expect("snapshot file"))); - } -} - -/// The one secret the scaffolded source needs and `provision` leaves out, so a -/// test can choose whether the project is complete. fn provision_bearer_token(project: &Path) { let out = project.join("secrets/source-bearer-token"); run_ok(&["keygen", "token", "--out", out.to_str().expect("token")]); diff --git a/crates/registry-evidencectl/tests/production_handoff.rs b/crates/registry-evidencectl/tests/production_handoff.rs index a5ee53733c..d1ef057a03 100644 --- a/crates/registry-evidencectl/tests/production_handoff.rs +++ b/crates/registry-evidencectl/tests/production_handoff.rs @@ -2,7 +2,9 @@ //! Exact production-candidate handoff through the real adopter and runtime //! binaries. The gate is ignored in the ordinary package suite because it -//! starts two services and requires `python3` and `openssl` on the host. +//! starts services and requires `python3` and `openssl` on the host. The pinned +//! stock-issuer journey lives in the Evidence client acceptance suite; this +//! suite keeps the strict production HTTPS, signing, and audit handoff proof. use std::{ collections::BTreeMap, @@ -245,129 +247,6 @@ fn production_candidate_handoff_reaches_verified_assertion_and_audit() { ); } -#[test] -#[ignore = "exact gate: starts real Mint and Evidence plus local HTTPS routing"] -fn production_candidate_accepts_a_token_from_an_independent_real_mint() { - let fixture = Fixture::new(); - let evidence = evidence_binary(); - let mint = mint_binary(); - fixture.stage_authoring_project(); - fixture.stage_https_identity(); - fixture.stage_target(); - let _transit = fixture.start_transit(); - let build = fixture.build(evidence); - let revision = bundle_revision(&build); - fixture.provision_target_secrets(); - let mint_deployment = fixture.stage_mint(); - - assert_success( - Command::new(mint) - .args(["check", "--config"]) - .arg(&mint_deployment.config) - .output() - .expect("Mint check starts"), - "real Mint deployment check", - ); - assert_success( - evidencectl() - .args(["doctor", "--project"]) - .arg(&fixture.candidate) - .arg("--mint-config") - .arg(&mint_deployment.config) - .output() - .expect("paired doctor starts"), - "paired Evidence and Mint doctor", - ); - - let mut https = fixture.start_https(); - fixture.wait_for_https(&mut https); - let mut mint_service = fixture.start_mint(mint, &mint_deployment.config); - wait_for_listener(&mut mint_service, fixture.mint_port, "Mint"); - let mut evidence_service = fixture.start_evidence(evidence); - fixture.wait_for_evidence(&mut evidence_service); - - let public_token_endpoint = format!("https://127.0.0.1:{}/token", fixture.https_port); - let token_output = Command::new(mint) - .arg("token") - .arg("--url") - .arg(&public_token_endpoint) - .arg("--audience") - .arg(public_token_endpoint) - .args(["--client-id", "acceptance-client", "--key"]) - .arg(&mint_deployment.caller_private) - .arg("--ca-certificate") - .arg(&fixture.ca) - .output() - .expect("Mint token starts"); - assert!( - token_output.status.success(), - "Mint token failed without printing a token: {}", - String::from_utf8_lossy(&token_output.stderr) - ); - let token = String::from_utf8(token_output.stdout).expect("Mint token stdout"); - assert_eq!( - token.lines().count(), - 1, - "Mint prints exactly one token line" - ); - let token = token.trim(); - - let published_revision = published_configuration_revision(fixture.evidence_port, token); - let nonce = URL_SAFE_NO_PAD.encode([0x24_u8; 32]); - let (status, response) = post_evidence(fixture.evidence_port, token, &nonce); - assert_eq!(status, 200, "a real Mint token must authorize Evidence"); - fs::write(&fixture.response, &response).expect("retain Mint-backed response"); - fs::set_permissions(&fixture.response, fs::Permissions::from_mode(0o600)) - .expect("protect Mint-backed response"); - let payload = signed_payload(&response); - assert_eq!(payload["assuranceProfile"], "production"); - assert_eq!(payload["configurationRevision"], published_revision); - assert_eq!(payload["supportedValues"][0]["providesValueFor"], CONCEPT); - assert_eq!(payload["supportedValues"][0]["value"], true); - assert!( - !serde_json::to_vec(&payload) - .expect("Mint-backed payload serializes") - .windows(token.len()) - .any(|part| part == token.as_bytes()), - "signed payload retained the Mint access token" - ); - - fixture.write_verification_policy(&payload, &nonce, &published_revision); - assert_success( - Command::new(evidence) - .arg("verify") - .arg("--jws") - .arg(&fixture.response) - .arg("--jwks") - .arg(&fixture.evidence_jwks) - .arg("--policy") - .arg(&fixture.policy) - .output() - .expect("Mint-backed response verifier starts"), - "Mint-backed independent response verification", - ); - let source_token = fs::read(&fixture.source_token).expect("source token"); - assert_audit_contract( - &wait_for_audit(&fixture.audit_path), - &revision, - &fixture.evidence_signing_kid(), - &[source_token.as_slice(), token.as_bytes()], - ); - - stop_gracefully(&mut evidence_service, "Evidence"); - stop_gracefully(&mut mint_service, "Mint"); - stop_forcefully(&mut https); - assert_success( - Command::new(evidence) - .arg("--runtime") - .arg(fixture.candidate.join("runtime.yaml")) - .arg("verify-audit") - .output() - .expect("Mint-backed audit verifier starts"), - "Mint-backed complete audit-chain verification", - ); -} - #[test] #[ignore = "exact gate: runs the real production builder and sibling Evidence bundle check"] fn production_build_accepts_the_real_bundle_check_revision() { @@ -734,7 +613,7 @@ fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { .arg("--docker-bin") .arg(docker) .args(["--evidence-port", &fixture.evidence_port.to_string()]) - .args(["--issuer-port", &fixture.mint_port.to_string()]) + .args(["--issuer-port", &fixture.issuer_port.to_string()]) .args(["--ready-timeout-seconds", "20"]) .output() .expect("public dev starts"), @@ -748,7 +627,7 @@ fn public_lifecycle_keeps_local_dev_state_out_of_the_production_candidate() { ))); assert!(started_stdout.contains(&format!( "Issuer ready at http://127.0.0.1:{}", - fixture.mint_port + fixture.issuer_port ))); let dev_root = fixture.project.join(".evidence/dev"); let local_bundle = fs::read(dev_root.join("bundle/evidence.yaml")).expect("local dev bundle"); @@ -857,11 +736,6 @@ impl Drop for DevStopGuard { } } -struct MintDeployment { - config: PathBuf, - caller_private: PathBuf, -} - struct TransitServer { stop: Arc, socket: PathBuf, @@ -902,7 +776,7 @@ struct Fixture { policy: PathBuf, https_port: u16, evidence_port: u16, - mint_port: u16, + issuer_port: u16, } impl Fixture { @@ -938,7 +812,7 @@ impl Fixture { policy: root.join("verification-policy.yaml"), https_port: ports[0], evidence_port: ports[1], - mint_port: ports[2], + issuer_port: ports[2], temporary, root, project, @@ -1746,8 +1620,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {{format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true}} @@ -1949,120 +1821,6 @@ authorityProfiles: ); } - fn stage_mint(&self) -> MintDeployment { - let mint = self.root.join("mint"); - let clients = mint.join("clients"); - fs::create_dir_all(&clients).expect("Mint client registry"); - - let mint_public_keys = mint.join("public-keys"); - fs::create_dir(&mint_public_keys).expect("Mint public key directory"); - let generated_mint_public = mint.join("mint-public.jwk.json"); - assert_success( - evidencectl() - .args(["keygen", "signing", "--out-dir"]) - .arg(mint.join("transit-key")) - .arg("--public-out") - .arg(&generated_mint_public) - .output() - .expect("Mint signing keygen starts"), - "independent Mint signing key generation", - ); - let mint_public_jwk: Value = - serde_json::from_slice(&fs::read(&generated_mint_public).expect("Mint public JWK")) - .expect("Mint public JWK parses"); - let mint_kid = mint_public_jwk["kid"].as_str().expect("Mint signing kid"); - let mint_public = mint_public_keys.join(format!("{mint_kid}.jwk.json")); - fs::rename(&generated_mint_public, &mint_public).expect("publish Mint public JWK"); - let audit = mint.join("audit"); - fs::create_dir(&audit).expect("Mint audit directory"); - fs::create_dir(mint.join("secrets")).expect("Mint secret directory"); - fs::set_permissions(mint.join("secrets"), fs::Permissions::from_mode(0o700)) - .expect("Mint secret directory mode"); - fs::set_permissions(&audit, fs::Permissions::from_mode(0o700)) - .expect("Mint audit directory mode"); - let audit_key = mint.join("secrets/mint-audit-hmac-key"); - let mut audit_key_file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&audit_key) - .expect("Mint audit key"); - audit_key_file - .write_all(b"production-handoff-mint-audit-key") - .expect("Mint audit key contents"); - audit_key_file.sync_all().expect("sync Mint audit key"); - assert_eq!( - fs::metadata(&audit) - .expect("Mint audit metadata") - .permissions() - .mode() - & 0o777, - 0o700, - ); - assert_eq!( - fs::metadata(&audit_key) - .expect("Mint audit key metadata") - .permissions() - .mode() - & 0o777, - 0o600, - ); - let caller_public = mint.join("caller-public.jwk.json"); - let caller_directory = mint.join("caller"); - assert_success( - evidencectl() - .args(["keygen", "signing", "--out-dir"]) - .arg(&caller_directory) - .arg("--public-out") - .arg(&caller_public) - .output() - .expect("Mint caller keygen starts"), - "independent Mint caller key generation", - ); - let caller_jwk: Value = - serde_json::from_slice(&fs::read(&caller_public).expect("Mint caller public JWK")) - .expect("Mint caller public JWK parses"); - fs::write( - clients.join("acceptance-client.yaml"), - format!( - "clientId: acceptance-client\nprincipal: urn:example:principals:acceptance-client\nevidenceAudience: {EVIDENCE_AUDIENCE}\nrequesterTags: [fixture-agency]\nkeys: [{}]\n", - serde_json::to_string(&caller_jwk).expect("caller JWK serializes") - ), - ) - .expect("Mint client registration"); - - // The HTTPS process now publishes Mint's public signing key at the - // configured public identity. Mint itself remains on a private plain - // HTTP listener behind that operator-owned route. - fs::remove_file(&self.oidc_jwks).expect("replace external IdP JWKS for Mint path"); - assert_success( - evidencectl() - .args(["jwks", "--out"]) - .arg(&self.oidc_jwks) - .arg(&mint_public) - .output() - .expect("Mint JWKS assembly starts"), - "Mint public JWKS assembly", - ); - - let identity = format!("https://127.0.0.1:{}", self.https_port); - let config = mint.join("mint.yaml"); - fs::write( - &config, - format!( - "version: 1\nissuer: {identity}\nlistener: {{address: 127.0.0.1, port: {port}}}\nsigning:\n algorithm: ES256\n activePublicJwkFile: public-keys/{mint_kid}.jwk.json\n publishedPublicJwkFiles: []\n revokedKeyIds: []\nsigner:\n kind: transit\n unixSocketPath: {transit_socket}\n mount: transit\n keyName: mint-signing\n keyVersion: 1\n timeoutMilliseconds: 2000\nsecretProviders:\n file:\n root: {secrets}\naudit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyRef: secret:file/mint-audit-hmac-key\n hashKeyVersion: 1\naccessTokens:\n audiences: [{TOKEN_AUDIENCE}]\n lifetimeSeconds: 300\n claims:\n principal: sub\n requesterTags: evidence_tags\n evidenceAudience: evidence_audience\n grantId: evidence_grant_id\n grantAuthority: evidence_authority\nclientAssertion:\n audience: {identity}/token\n algorithms: [ES256]\nclients:\n directory: clients\n", - port = self.mint_port, - transit_socket = self.root.join("transit-proxy.sock").display(), - secrets = mint.join("secrets").display(), - ), - ) - .expect("Mint config"); - MintDeployment { - config, - caller_private: caller_directory.join("signing-p256-private-jwk"), - } - } - fn start_https(&self) -> Child { Command::new("python3") .arg( @@ -2073,7 +1831,6 @@ authorityProfiles: .env("ACCEPTANCE_TLS_CERT", &self.tls_cert) .env("ACCEPTANCE_TLS_KEY", &self.tls_key) .env("ACCEPTANCE_JWKS", &self.oidc_jwks) - .env("ACCEPTANCE_MINT_PORT", self.mint_port.to_string()) .env("ACCEPTANCE_SOURCE_TOKEN", &self.source_token) .env("ACCEPTANCE_SOURCE_MARKER", &self.source_marker) .env("ACCEPTANCE_READY", &self.https_ready) @@ -2105,18 +1862,6 @@ authorityProfiles: .expect("Evidence service starts") } - fn start_mint(&self, mint: &Path, config: &Path) -> Child { - let log = owner_only_log(&self.root.join("mint.log")); - Command::new(mint) - .args(["serve", "--config"]) - .arg(config) - .stdin(Stdio::null()) - .stdout(Stdio::from(log.try_clone().expect("clone Mint log"))) - .stderr(Stdio::from(log)) - .spawn() - .expect("Mint service starts") - } - fn wait_for_evidence(&self, child: &mut Child) { wait_for(Duration::from_secs(20), || { assert_running(child, "Evidence"); @@ -2147,6 +1892,7 @@ authorityProfiles: "sub": "synthetic-caller", "iat": now - 1, "exp": now + 299, + "registry_actor_kind": "service", "evidence_tags": ["fixture-agency"], "evidence_audience": EVIDENCE_AUDIENCE, })) @@ -2357,7 +2103,6 @@ fn transit_signature(root: &Path, key_name: &str, body: &[u8]) -> Result Result { let path = match key_name { "evidence-signing" => root.join("transit-evidence-key/signing-p256-private-jwk"), - "mint-signing" => root.join("mint/transit-key/signing-p256-private-jwk"), _ => return Err("unknown Transit key"), }; let bytes = fs::read(path).map_err(|_| "Transit fixture key unavailable")?; @@ -2527,13 +2272,6 @@ fn free_ports(count: usize) -> Vec { .collect() } -fn wait_for_listener(child: &mut Child, port: u16, label: &str) { - wait_for(Duration::from_secs(20), || { - assert_running(child, label); - TcpStream::connect(("127.0.0.1", port)).is_ok() - }); -} - fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) { let deadline = Instant::now() + timeout; while Instant::now() < deadline { @@ -2799,43 +2537,6 @@ fn installed_binary(variable: &str, name: &str) -> PathBuf { .unwrap_or_else(|| panic!("set {variable} for this exact lifecycle gate")) } -fn mint_binary() -> &'static Path { - static BINARY: OnceLock = OnceLock::new(); - BINARY.get_or_init(|| { - if let Some(path) = std::env::var_os("MINT_BIN") { - return PathBuf::from(path); - } - let build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) - .current_dir(workspace_root()) - .args([ - "build", - "--locked", - "-p", - "registry-mint", - "--bin", - "mint", - "--profile", - ¤t_test_profile(), - "--message-format", - "json-render-diagnostics", - ]) - .output() - .expect("building the Mint binary"); - assert!( - build.status.success(), - "building the Mint binary failed: {}", - String::from_utf8_lossy(&build.stderr) - ); - String::from_utf8_lossy(&build.stdout) - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|message| message["reason"] == "compiler-artifact") - .filter_map(|message| message["executable"].as_str().map(PathBuf::from)) - .find(|path| path.file_name().is_some_and(|name| name == "mint")) - .expect("Mint executable path") - }) -} - fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() diff --git a/crates/registry-evidencectl/tests/support/production_handoff_https.py b/crates/registry-evidencectl/tests/support/production_handoff_https.py index 3c4a9320e5..e7faaeab5f 100644 --- a/crates/registry-evidencectl/tests/support/production_handoff_https.py +++ b/crates/registry-evidencectl/tests/support/production_handoff_https.py @@ -1,6 +1,5 @@ """Silent local HTTPS issuer and sanitized Evidence source for acceptance tests.""" -import http.client import http.server import json import os @@ -21,9 +20,6 @@ def do_GET(self): self.send_error(404) def do_POST(self): - if self.path == "/token" and os.environ.get("ACCEPTANCE_MINT_PORT"): - self._proxy_mint_token() - return if self.path != "/v1/facts": self.send_error(404) return @@ -49,44 +45,6 @@ def do_POST(self): pathlib.Path(os.environ["ACCEPTANCE_SOURCE_MARKER"]).write_text("requested\n") self._json(b'{"total":1,"date_of_birth":"2000-01-01"}') - def _proxy_mint_token(self): - try: - length = int(self.headers.get("Content-Length", "0")) - except ValueError: - self.send_error(400) - return - if length <= 0 or length > 65536: - self.send_error(413) - return - body = self.rfile.read(length) - upstream = http.client.HTTPConnection( - "127.0.0.1", int(os.environ["ACCEPTANCE_MINT_PORT"]), timeout=5 - ) - try: - upstream.request( - "POST", - "/token", - body=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - response = upstream.getresponse() - response_body = response.read(1048577) - if len(response_body) > 1048576: - self.send_error(502) - return - self.send_response(response.status) - self.send_header( - "Content-Type", response.getheader("Content-Type", "application/json") - ) - self.send_header("Content-Length", str(len(response_body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(response_body) - except (OSError, http.client.HTTPException): - self.send_error(502) - finally: - upstream.close() - def _json(self, body): self.send_response(200) self.send_header("Content-Type", "application/json") diff --git a/crates/registry-mint/Cargo.toml b/crates/registry-mint/Cargo.toml deleted file mode 100644 index 30299ee498..0000000000 --- a/crates/registry-mint/Cargo.toml +++ /dev/null @@ -1,58 +0,0 @@ -[package] -name = "registry-mint" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -description = "Minimal OAuth token issuer for registered machine clients." -repository.workspace = true -publish = false - -[[bin]] -name = "mint" -path = "src/main.rs" - -[[bench]] -name = "replay_cache" -harness = false - -[lints] -workspace = true - -[dependencies] -async-trait.workspace = true -axum.workspace = true -base64.workspace = true -clap.workspace = true -getrandom.workspace = true -http.workspace = true -jsonwebtoken.workspace = true -registry-platform-canonical-json.workspace = true -registry-platform-audit.workspace = true -registry-platform-authcommon.workspace = true -registry-platform-buildinfo.workspace = true -registry-platform-config.workspace = true -registry-platform-crypto = { workspace = true, features = ["transit"] } -registry-platform-httputil.workspace = true -registry-platform-oidc.workspace = true -reqwest.workspace = true -rustix.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_norway.workspace = true -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -tower-http.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -ulid.workspace = true -url.workspace = true -zeroize.workspace = true - -[dev-dependencies] -axum-test.workspace = true -ed25519-dalek.workspace = true -p256.workspace = true -registry-evidence.workspace = true -tempfile.workspace = true diff --git a/crates/registry-mint/README.md b/crates/registry-mint/README.md deleted file mode 100644 index 555e881b75..0000000000 --- a/crates/registry-mint/README.md +++ /dev/null @@ -1,563 +0,0 @@ -# Registry Mint - -Mint issues short-lived access tokens to registered machine clients. It exists -so that a resource server such as Evidence Gateway or Registry Relay can -require signed, expiring, audience-bound tokens without the deployment first -having to stand up a general purpose identity provider. - -Mint is not a product line. It is a small supporting service for deployments -that have many callers and no IdP. - -## Why a server, and not just a shared JWKS - -A resource server configured with a pooled JWK set can answer only one -question: *was this token signed by one of the trusted keys?* It cannot answer -the question that authorization actually depends on: *which caller signed it, -and what is that caller permitted to assert?* - -Key selection inside a JWK set is by `kid`, and `kid` is chosen by whoever -built the token. So in a pooled set every key is equally authoritative for -every claim. Any client holding any trusted key can mint a token naming any -principal or authorization claim. - -Mint closes that by splitting the two questions across two places: - -- The **client registry** (`clients/*.yaml`) binds a client id to *that - client's* public keys and to the authority Mint will assert for it. -- The **token endpoint** verifies an incoming client assertion against the - keys of the client it claims to be, selected *before* signature - verification, and then writes the authority from the registry, never from - the assertion payload. - -A client therefore holds its own key and signs for itself, but possession of a -key no longer decides what may be said. - -## Protocol - -Mint speaks the `client_credentials` grant. `private_key_jwt` client -authentication (RFC 7523) remains the default. A client builds a short-lived -JWT assertion signed with its own private key, and posts it to the token -endpoint: - -```http -POST /token -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials -&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -&client_assertion= -``` - -The assertion must carry `iss` and `sub` equal to the client id, `aud` equal to -the configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside the -configured maximum lifetime. Every `jti` is single use: presenting the same -assertion twice is refused. - -A managed client that cannot sign assertions may use the explicitly selected -client-secret compatibility profile. Mint accepts both `client_secret_basic` -and `client_secret_post`. The registration stores one or two canonical SHA-256 -fingerprints, never the raw secret: - -```http -POST /token -Authorization: Basic -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials -``` - -Form-encode the client id and secret separately before joining them with the -colon delimiter and Base64-encoding the result. The same request may carry -`client_id` and `client_secret` in the form body for clients that implement -`client_secret_post`. One request may use only one authentication method. -Client-secret registrations are standard-authorization clients only; they -cannot issue Evidence or delegated authority. - -The response is a signed `at+jwt` access token. Errors collapse to -`invalid_client` so that the endpoint cannot be used to probe which client ids -are registered. - -Each client registration selects one authority profile. An Evidence profile -writes configurable Evidence claims. A standard authorization profile writes -one space-delimited OAuth `scope` claim plus bounded direct string or -string-list claims. -The two profiles cannot be combined in one registration, and neither profile -accepts authority from the token request. - -Endpoints: - -| Path | Purpose | -|---|---| -| `POST /token` | Issue an access token | -| `GET /.well-known/jwks.json` | Public keys for verifying minted tokens (path is configurable) | -| `GET /.well-known/oauth-authorization-server` | Metadata pointing at the above | -| `GET /.well-known/openid-configuration` | Equivalent discovery metadata for OIDC-compatible resource servers | -| `GET /health`, `GET /ready` | Liveness, and readiness (503 without clients, a ready signer, or a writable audit chain) | - -## Configuration - -One YAML document. Governed public-key, audit, and client-registry paths resolve -relative to the document's own directory. The secret root and Transit socket -are absolute. Everything here is startup-only: issuer identity, signing and -audit keys, listener, and token policy are fixed for the life of the process. - -`audit.path` is resolved further, at load: every component of the destination -that exists is followed to where it really points, and Mint records that -location as the one it opens. A destination that resolves under the -configuration directory has to stay there, so a link component leading out of -it is refused before startup. An absolute destination elsewhere names storage -the operator chose, and only its links are resolved. A destination that cannot -be resolved fails closed, and no refusal names a configured path. - -```yaml -version: 1 -issuer: https://mint.example.org -listener: - address: 127.0.0.1 - port: 8081 -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: 7 - timeoutMilliseconds: 2000 -secretProviders: - file: - root: /run/registry-mint/secrets -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority - # Optional. Required only to issue delegated tokens; see below. - actor: evidence_actor -clientAssertion: - audience: https://mint.example.org/token - maximumLifetimeSeconds: 300 - algorithms: [EdDSA] -clients: - directory: clients -``` - -The `accessTokens.claims` names must match Evidence Gateway's `authentication` -block because Evidence Gateway reads its principal, requester tags, evidence -audience, and grant pair from configurable claim names. A scoped-only -deployment may omit `accessTokens.claims`. Access token lifetime is bounded to -60..=3600 seconds; a long-lived bearer token is the thing Mint exists to avoid. - -Mint's service key is always P-256/ES256. Each governed public JWK carries a -`kid` equal to its 43-character RFC 7638 thumbprint and is stored as -`.jwk.json`. Strict deployments use a non-exportable Vault/OpenBao -Transit key through the configured workload-local Unix socket. Mint receives -no provider token. - -Supervised local development may replace the `signer` block with: - -```yaml -signer: - kind: local-jwk - privateKeyRef: secret:file/mint-signing -``` - -The referenced private JWK must exactly match `activePublicJwkFile`. Secret -files are resolved beneath `secretProviders.file.root` and must be regular, -single-link, owner-only files. Never commit, print, or pass them on a command -line. Client assertion keys remain independently owned and may use EdDSA, -ES256, or RS256 with their own identifiers. - -Generate every compatibility-profile secret with Mint itself: - -```bash -mint client-secret generate --out /run/registry-mint/secrets/qgis-west.secret -``` - -The command creates a new owner-only file, refuses to replace an existing -file, and prints only its `sha256:...` fingerprint. The raw secret never -appears on stdout or in the registration. Copy the file to the one managed -client installation that will use it, through the deployment's secret-delivery -channel. Do not share one secret across people or installations. - -The audit key file is also owner-only and must contain at least 32 bytes. The -audit directory, chain, and lock file must be owned by the Mint process user and -unavailable to group and other users. For a new deployment, `openssl rand -hex -32 > /run/registry-mint/secrets/audit-hmac-key` followed by `chmod 600 -/run/registry-mint/secrets/audit-hmac-key` is sufficient. Mint derives separate -HKDF subkeys for chain integrity and identifier pseudonyms. It verifies the -keyed chain at startup and holds a single-writer lock for the process lifetime. It writes a durable -release record before returning every access token; if that write fails, the -request returns `server_error` and readiness fails. Denials are recorded with -value-free error categories. Raw assertions, tokens, client ids, actors, -principals, and subject values never enter the chain; successful records use -keyed pseudonyms where correlation is needed. - -`audit.maximumFileBytes` is a per-segment threshold, not a total capacity limit. -When an append would exceed the threshold, Mint seals the active segment as -`.` and opens a new active segment online. The -keyed chain continues across the seam. Mint never deletes or compacts sealed -segments, so monitor total capacity and archive sealed history under the -deployment's retention policy while retaining the matching audit key. Never -rename or archive the active segment while Mint is running. - -Audit master rotation starts a new epoch. Stop Mint, record and archive the old -chain head, runtime, key, and segments, then increment `hashKeyVersion`, select -a fresh audit path, install the new key, run `mint check`, and restart. Never -append a replacement audit master to an existing chain. - -For planned service-key rotation, create the next Transit version, publish its -public JWK first, and deploy and restart every replica with that overlap set. -Only after every replica publishes both keys, switch `activePublicJwkFile` and -the pinned `keyVersion` together while leaving the old key published, then -deploy and restart every replica again. Remove the old public key and raise the -Transit key's `min_encryption_version` only after the maximum access-token -lifetime plus consumer clock skew. For compromise, disable provider signing -authority immediately, remove its JWK, add its thumbprint to `revokedKeyIds`, -and activate a replacement or leave Mint unavailable. Add the compromised Mint -thumbprint to each Evidence consumer's `authentication.revokedKeyIds` in the -same incident rollout. Configuration is startup-only, so every rotation step -takes effect through a restart. - -Mint client-key rotation is independent of the service key. Add the new public -client key, reload, move the client, and retain the old public key for the -configured maximum client-assertion lifetime plus 30 seconds before removing -and reloading again. Remove a compromised client key immediately and reload; -do not provide an overlap window during an incident. - -Client-secret rotation uses the same registry reload boundary. Generate a new -secret, add its fingerprint beside the old fingerprint, reload, update the -managed client, then remove the old fingerprint and reload again. A -registration accepts at most two fingerprints so this overlap cannot become an -unbounded secret set. Remove a revoked fingerprint only while another valid -fingerprint remains. To revoke the last or only secret, remove the complete -registration and reload. An empty fingerprint list is invalid and leaves the -previous registry active after the failed reload. Access tokens already released -remain valid until their short configured expiry. - -## Registering clients - -### Evidence authority - -One `*.yaml` file per client in `clients.directory`: - -```yaml -clientId: health-desk -principal: service:health-desk -evidenceAudience: https://evidence.example.org -requesterTags: [health-desk, region-north] -# Optional. Minted only for callers acting under a recorded authority. -grant: - id: grant-2026-014 - authority: ministry-of-health -keys: - - kty: OKP - crv: Ed25519 - kid: health-desk-2026-01 - x: "..." -``` - -Only public JWKs are accepted; a document carrying a private member is -rejected. The load is all-or-nothing, so one malformed registration fails the -whole load and a partially applied registry can never serve. - -### Standard scoped authority - -Use `authorization` for a Registry Relay client or another OAuth resource -server that reads the standard `scope` claim: - -```yaml -clientId: registry-consumer -principal: urn:example:consumer -authorization: - scopes: - - registry:business:read - claims: - purpose: statutory-consultation - authority: district-17 -keys: - - kty: OKP - crv: Ed25519 - kid: registry-consumer-2026-01 - x: "..." -``` - -Mint joins the registered scopes with one ASCII space, writes the result as -the token's `scope` claim, and returns the same string in the token response's -optional `scope` member. The direct claims are server-governed. A claim value -is either one string, minted as a JSON string, or a list of strings such as -`authority: [district-17, district-18]`, minted as a JSON array for a resource -server boundary that matches a row against a set of permitted values. The -client assertion and token request cannot add, narrow, or replace them. - -A scoped registration has 1 to 64 unique RFC 6749 scope-tokens and at most 32 -direct claims, and a listed claim carries 1 to 64 unique values. It cannot use -`evidenceAudience`, `requesterTags`, `grant`, or `delegation`. Direct claim -names cannot shadow `iss`, `aud`, `exp`, `iat`, `nbf`, `jti`, `client_id`, -`sub`, or `scope`. -When the deployment also configures Evidence claim names, a scoped client's -direct claims cannot reuse any of those names. - -Standard-profile tokens have a maximum configured lifetime of 900 seconds. -Mint also projects the complete signed token response at startup and reload and -refuses a registration that would exceed the shared client's 16 KiB response -ceiling. Evidence-profile deployments retain the general 60 to 3600 second -range. - -For Registry Relay, configure `accessTokens.audiences` with the one exact -audience from the Relay runtime. Register only scopes and direct claims that -the Relay contract uses for the intended operation and access profile. Mint -remains an ordinary conforming issuer; Relay has no Mint-specific runtime -branch. - -For a managed client such as one QGIS installation, explicitly select the -client-secret compatibility profile and replace `keys` with one or two -fingerprints: - -```yaml -clientId: qgis-west -principal: urn:example:qgis-installation:west -authorization: - scopes: - - registry:business:read -clientAuthentication: - method: client-secret - secretFingerprints: - - sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef -``` - -Omitting `clientAuthentication` keeps the stronger `private-key-jwt` default. -A secret authenticates that installation, not the person using it, and must -therefore receive only the least authority that installation needs. Use one -registration and secret per managed installation when independent rotation, -revocation, or audit attribution matters. - -## Delegation: a token bound to one subject - -A caller can be issued a token that is valid only for evidence *about one named -person*. The point is containment rather than labelling: a client that loops -over the wrong list or reuses a request object cannot cross from one person to -another, because the subject is not a request parameter it controls. - -Three things have to line up. - -**The registration** says which agents this client may act as, and which -selector fields it may bind, at which claim paths: - -```yaml -clientId: appointment-scheduler -principal: service:appointment-scheduler -evidenceAudience: https://scheduler.example.org -requesterTags: [scheduling-agent] -delegation: - # Optional. Omitted means the client names its own actor. - actors: [urn:example:agent:appointment-scheduler] - subjectClaims: - given_name: identity.given_name - family_name: identity.family_name - birth_date: identity.birth_date -keys: [...] -``` - -A client with no `delegation` block cannot obtain a delegated token at all. - -**The request** names the actor and the subject inside the client's own signed -assertion, in an `on_behalf_of` member: - -```json -{ - "iss": "appointment-scheduler", - "sub": "appointment-scheduler", - "aud": "https://mint.example.org/token", - "iat": 1785671511, "exp": 1785671631, "jti": "...", - "on_behalf_of": { - "actor": "urn:example:agent:appointment-scheduler", - "subject": {"given_name": "...", "family_name": "...", "birth_date": "..."} - } -} -``` - -Placing it inside the assertion is deliberate: the actor and the subject are -covered by the client's signature, so nothing between the client and Mint can -alter who the token is for. `on_behalf_of` is Mint's own member rather than RFC -8693 `act`, because token exchange presents a subject's own credential, which is -exactly what a deployment without an identity provider does not have. - -The subject must carry the registration's subject fields exactly: a missing -field or an extra one is refused, like every other delegation failure, as -`invalid_client`. - -**The resource server bundle** declares that subject role's `valueOrigin` as -`authenticated-context`, with `valueClaims` mirroring `subjectClaims` above. -That is what makes the property hold: Evidence reads the selector from the token -and rejects any request that carries selector values of its own. A request -naming a different person is refused for carrying values at all, not for -carrying the wrong ones. - -Two limits worth stating plainly. This defends against a *buggy* client, not a -*compromised* one: a client holding its own signing key can ask Mint for a token -naming a different subject, within the fields its registration permits. And -Evidence confines an actor-bearing token to `kind: delegated` authority profiles -but does not conversely require an actor to reach one, so an undelegated token -matches such a grant and is stopped when the subject cannot be resolved. - -[`demo/`](demo/) runs all of this end to end against the real binaries, with -every request printed before it is sent. - -The registry is the one reloadable part of Mint. `SIGHUP` reloads it in place, -keeping the previous registry if the new one does not load. Onboarding, -offboarding, and caller key rotation therefore never restart the resource -server. - -## Running - -```bash -mint check --config /etc/mint/mint.yaml -``` - -The ordinary check can run while Mint is serving because it does not claim the -audit writer. Before first startup or a replacement deployment, add -`--require-runtime-dependencies` to prove the signer and writable audit chain -through the same initialization path as `mint serve`. - -Add `--require-audit-under ` alongside it to also prove -that the configured audit sink resolves at or below a directory the deployment -declares persistent. Mint resolves `audit.path` exactly as it loads it, -against the configuration file's directory when the setting is relative, then -canonicalizes the declared root and the deepest existing ancestor of the sink -before comparing. A relative `--config` path works the same way, because the -configuration directory is resolved to its canonical location as the file -loads. A sink outside the root, a symlink inside the root that leads to -ephemeral storage, and an `audit.path` that climbs with `..` all fail closed; -a climbing component cannot be proven to stay inside the root. The option -relaxes nothing: the -signer and audit chain proofs still run. Deciding whether the declared root is -durable storage belongs to the deployment that mounts it; Mint only proves -where its own configured sink resolves, and reports the failing side without -naming a path. - -`mint healthcheck` probes a numeric loopback or private-address `/ready` -endpoint with a bounded, proxy-free HTTP client and accepts only Mint's exact -minimal ready response. Set `MINT_HEALTHCHECK_URL` when the container binds a -private address instead of the loopback default. The command is for container -and process supervision; it prints no response body. - -```bash -mint serve --config /etc/mint/mint.yaml -``` - -Verify the retained chain with the same configuration and audit key: - -```bash -mint verify-audit --config /etc/mint/mint.yaml -``` - -The command verifies every sealed segment. It also verifies the active segment -when no Mint process owns the writer lock; otherwise it reports -`active-segment: not verified`. - -All three operator commands accept `MINT_CONFIG` in place of `--config`. -`check` loads the configuration, signing key, audit chain, and client registry, -then exits without opening a socket. - -Mint serves plain HTTP and expects to sit behind TLS termination it does not -manage. - -## Getting a token in development - -```bash -mint token --url https://mint.example.org/token \ - --client-id scheduler --key ./dev/scheduler.jwk -``` - -It prints the access token on stdout and nothing else, so `TOKEN=$(mint token -...)` is the whole usage. Diagnostics go to stderr; `--verbose` prints the -endpoint's full response instead. - -This is a *client* tool. It signs a client assertion with the caller's own key -and presents it to a running endpoint, exactly as an adopter's client library -would. It reads no server configuration and never touches Mint's signing key. -There is deliberately no subcommand that signs an access token directly: that -would be a way to obtain authority without authenticating, inside the binary -whose purpose is to make authority depend on authentication. Anything `mint -token` can obtain, the same client could have obtained over the wire. - -The key file gets the same treatment as Mint's own signing key: a regular file, -owned by you, unreadable by group and other, reached without traversing a -symlink. - -`mint token` intentionally exercises the preferred private-key method. For a -standard OAuth client that needs a secret, use `mint client-secret generate` -to provision it and let the client call `/token` with -`client_secret_basic` or `client_secret_post`. - -For a delegated token: - -```bash -mint token --url https://mint.example.org/token \ - --client-id scheduler --key ./dev/scheduler.jwk \ - --actor urn:example:agent:appointment-scheduler \ - --subject-file ./dev/subject.json -``` - -`--subject-file` holds a flat JSON object of selector fields -(`{"given_name": "Amara", "birth_date": "1998-04-02"}`). It is a file rather -than repeated flags because those are a real person's identifying details, and -command lines are visible to every process on the host and land in shell -history. - -Two more flags matter in development. `--audience` overrides the assertion -audience, which defaults to `--url`; they differ when the endpoint is reached -over loopback but configured with its public URL. `--ca-certificate` trusts a -PEM bundle in addition to the system roots, for a deployment behind a private -CA. - -## Verify a change - -```bash -cargo test --locked -p registry-mint -``` - -Measure the replay cache with synthetic namespaced assertion identifiers: - -```bash -cargo bench --locked -p registry-mint --bench replay_cache -``` - -The benchmark covers insertion, duplicate rejection, saturation, and cohort -or staggered expiry at 1,024, the default 8,192, and 100,000 entries. It reports -five samples per case without a timing gate. These are cache costs; token -endpoint throughput also includes authentication, signing, and durable audit. - -`tests/evidence_compatibility.rs` is the test that justifies the crate: it -drives the real router over a real on-disk deployment and feeds the minted -token to Evidence's own authenticator. `tests/delegated_subject_binding.rs` -does the same for delegation, running Evidence's own entitlement match and -selector resolution over a token from the real Mint router. The dependency runs -one way only. Evidence does not depend on Mint. - -`registry-relay-v2`'s `tests/acceptance_http.rs` also starts the real Mint and -Relay routers. The Relay client obtains a token through its shared -private-key-JWT provider, then uses Mint's registered scope, purpose, and row -authority for a protected lookup. Relay's production crates do not depend on -Mint. - -`tests/token_cli.rs` runs `mint token` against a real `mint serve` as two -processes, which is the only place the stdout contract can be observed. -`tests/client_secret_compatibility.rs` drives both standard secret methods -through the real router, verifies the released token with the shared OIDC -verifier, and covers renewal, rotation, revocation, authority containment, and -audit redaction. `tests/client_secret_cli.rs` proves the provisioning command's -owner-only file and stdout contracts as a real process. diff --git a/crates/registry-mint/benches/replay_cache.rs b/crates/registry-mint/benches/replay_cache.rs deleted file mode 100644 index 805746401b..0000000000 --- a/crates/registry-mint/benches/replay_cache.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Run with `cargo bench --locked -p registry-mint --bench replay_cache`. -//! -//! Synthetic, single-threaded replay-cache costs, excluding assertion parsing, -//! signature verification, signing, and durable audit. Inputs are prepared -//! before timing. No timing threshold is a correctness gate. - -use std::{hint::black_box, time::Instant}; - -use registry_mint::replay::{ReplayCache, ReplayError}; - -const SAMPLES: usize = 5; -const OPERATIONS: usize = 512; - -fn measure(operations: usize, mut operation: impl FnMut(usize)) -> u128 { - let start = Instant::now(); - for index in 0..operations { - operation(index); - } - start.elapsed().as_nanos() / operations as u128 -} - -fn report(name: &str, entries: usize, mut samples: Vec) { - samples.sort_unstable(); - println!( - "replay/{name} entries={entries} median_ns={} min_ns={} max_ns={} samples={}", - samples[samples.len() / 2], - samples[0], - samples[samples.len() - 1], - samples.len(), - ); -} - -fn benchmark(entries: usize) { - let keys: Vec<_> = (0..entries) - .map(|index| format!("client-registered\0assertion-{index:026}")) - .collect(); - let replacements: Vec<_> = (0..OPERATIONS) - .map(|index| format!("client-registered\0replacement-{index:026}")) - .collect(); - let mut fill = Vec::new(); - let mut duplicate = Vec::new(); - let mut saturated = Vec::new(); - let mut expire_cohort = Vec::new(); - let mut staggered = Vec::new(); - - for _ in 0..SAMPLES { - let cache = ReplayCache::new(entries); - fill.push(measure(entries, |index| { - assert_eq!(cache.remember(black_box(&keys[index]), 300, 0), Ok(())); - })); - duplicate.push(measure(OPERATIONS, |index| { - assert_eq!( - cache.remember(black_box(&keys[index % entries]), 300, 0), - Err(ReplayError::AlreadyUsed) - ); - })); - saturated.push(measure(OPERATIONS, |index| { - assert_eq!( - cache.remember(black_box(&replacements[index]), 300, 0), - Err(ReplayError::Saturated) - ); - })); - expire_cohort.push(measure(1, |_| { - assert_eq!( - cache.remember(black_box(&replacements[0]), 600, 300), - Ok(()) - ); - })); - assert_eq!(cache.len(), 1); - - let cache = ReplayCache::new(entries); - for (index, key) in keys.iter().enumerate() { - let expiry = if index < OPERATIONS { - index as i64 + 1 - } else { - 600 - }; - cache.remember(key, expiry, 0).unwrap(); - } - // One entry expires before each call; unlike the cohort case, a cache - // cannot avoid cleanup simply because the clock has not advanced. - staggered.push(measure(OPERATIONS, |index| { - assert_eq!( - cache.remember( - black_box(&replacements[index]), - index as i64 + 601, - index as i64 + 1, - ), - Ok(()) - ); - })); - assert_eq!(cache.len(), entries); - } - - report("fill_per_insert", entries, fill); - report("live_duplicate", entries, duplicate); - report("saturated", entries, saturated); - report("expire_cohort_total", entries, expire_cohort); - report("staggered_expiry", entries, staggered); -} - -fn main() { - for entries in [1_024, 8_192, 100_000] { - benchmark(entries); - } -} diff --git a/crates/registry-mint/demo/.gitignore b/crates/registry-mint/demo/.gitignore deleted file mode 100644 index 3b785b09b2..0000000000 --- a/crates/registry-mint/demo/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# The throwaway deployment `run.sh` provisions: keys, certificates, configs, -# audit log. Regenerated on every run, never committed. -.run/ diff --git a/crates/registry-mint/demo/README.md b/crates/registry-mint/demo/README.md deleted file mode 100644 index 7c58622b55..0000000000 --- a/crates/registry-mint/demo/README.md +++ /dev/null @@ -1,278 +0,0 @@ -# Delegated, subject-bound access: a runnable demonstration - -An agent needs to know which region one person lives in. It must not be able to -learn that about anybody else, even if the agent's own code is wrong. - -This directory runs that end to end against the real Mint and the real Evidence -binaries: a real client assertion, a real token, a real signed evidence -assertion, and a real refusal. - -```bash -crates/registry-mint/demo/run.sh -``` - -It needs `cargo`, `uv`, and `openssl`, binds four loopback ports (8080, 8090, -8092, 8443), and leaves its throwaway deployment in `demo/.run/` for inspection. -Everything it generates is disposable: fresh keys per run, synthetic people, a -private CA that exists for the lifetime of the demonstration. - -## What to read - -- [`walkthrough.py`](walkthrough.py) is the demonstration. Six steps, every - request printed before it is sent, with the reasoning next to it. -- [`evidence-bundle/evidence.yaml`](evidence-bundle/evidence.yaml) is the - policy. The security property is one line of it. -- Everything under [`support/`](support/) is deployment plumbing: key - generation, a TLS terminator, a stand-in registry source. None of it decides - anything. Read it only if you want to know why the demonstration needs a - certificate. - -## The mechanism, in one paragraph - -The client authenticates to Mint with a JWT it signs with its own key -(RFC 7523 `private_key_jwt`), so there is no shared secret and Mint holds only -public keys. The delegation request rides *inside* that signed JWT, in an -`on_behalf_of` member, which is what makes the actor and the subject -tamper-evident between the client and Mint. Mint checks both against the -client's registration and mints them as claims. The Evidence bundle then -declares that subject role's `valueOrigin` as `authenticated-context`, which -means Evidence reads the selector from those claims and **refuses any request -that carries selector values of its own**. That refusal is the containment: it -is not "you named the wrong person", it is "you do not get to name a person". - -## The flow - -```mermaid -sequenceDiagram - autonumber - participant Client - participant Mint as Registry Mint - participant Evidence - participant Source as Registry source - - Note over Client: Signs a client assertion with its own private key.
The delegation request rides inside that signature. - Client->>Mint: POST /token, assertion carrying on_behalf_of - Mint->>Mint: Verify the signature against the keys registered for scheduler - Mint->>Mint: Check the actor and the subject fields against that registration - Mint->>Mint: Durably append the keyed token-release audit record - Mint-->>Client: Access token with evidence_actor and identity.* claims - - Note over Client,Evidence: The person is named nowhere in the request below. - Client->>Evidence: POST /v1/evidence, requirement and purpose only - Evidence->>Evidence: Match the delegated authority profile - Evidence->>Evidence: Read the selector from the token (valueOrigin: authenticated-context) - Evidence->>Source: One fixed-authority lookup, for that person only - Source-->>Evidence: Registry record - Evidence-->>Client: Signed assertion: coarse region, opaque subject binding - - Note over Client,Evidence: The containment, with the same valid token. - Client->>Evidence: POST /v1/evidence carrying selector values for someone else - Evidence--xClient: 400 request.selector_invalid -``` - -Two properties are visible in the shape of that diagram. Nothing the client -sends after step 1 names a person, and the only arrow that reaches the registry -source is the one Evidence draws for the subject its own authority context -resolved. The final refusal is not a lookup that failed; it is a request that -was never allowed to describe anybody. - -## The four requests - -These are the requests the walkthrough sends, written as curl so they can be -read without running anything. `run.sh` stops its servers when it exits, so to -issue them by hand you would keep the deployment in `.run/` and start the four -processes yourself. - -### 1. The client assertion - -The client builds and signs this itself. Nothing between it and Mint can change -who the token is for. - -```json -{ - "iss": "scheduler", - "sub": "scheduler", - "aud": "https://localhost:8443/token", - "iat": 1785671511, - "exp": 1785671631, - "jti": "demo-1", - "on_behalf_of": { - "actor": "urn:example:demo:agent:appointment-scheduler", - "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} - } -} -``` - -`on_behalf_of` is Mint's own member, not RFC 8693 `act`. Token exchange presents -a subject's own credential, which is exactly what a deployment without an -identity provider does not have. - -### 2. The token request - -```bash -curl -sS --cacert crates/registry-mint/demo/.run/ca.pem https://localhost:8443/token \ - -d grant_type=client_credentials \ - -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ - --data-urlencode "client_assertion@/path/to/assertion.jwt" -``` - -Mint verifies the signature against the keys registered for `scheduler`, then -checks the delegation request against the same registration: - -```yaml -clientId: scheduler -principal: urn:example:demo:principal:scheduler -evidenceAudience: https://scheduler.demo.invalid -requesterTags: [demo-agent] -keys: [...] -delegation: - actors: [urn:example:demo:agent:appointment-scheduler] - subjectClaims: - given_name: identity.given_name - family_name: identity.family_name - birth_date: identity.birth_date -``` - -That block is the whole authorization decision Mint makes: which agents this -client may act as, and which selector fields it may bind, at which claim paths. -Neither answer comes from the request. A client with no `delegation:` block -cannot obtain a delegated token at all. - -The resulting token: - -```json -{ - "iss": "https://localhost:8443", - "sub": "urn:example:demo:principal:scheduler", - "aud": "evidence.demo.invalid", - "client_id": "scheduler", - "evidence_tags": ["demo-agent"], - "evidence_audience": "https://scheduler.demo.invalid", - "evidence_actor": "urn:example:demo:agent:appointment-scheduler", - "identity": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, - "exp": 1785671811, "iat": 1785671511, "nbf": 1785671511, "jti": "01KZ..." -} -``` - -`evidence_actor` says who is acting. `identity.*` says who they are acting for. - -### 3. The evidence request - -```bash -curl -sS http://127.0.0.1:8080/v1/evidence \ - -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json' \ - -d '{ - "requestNonce": "<32 random bytes, base64url, unpadded>", - "requirement": "urn:example:demo:requirement:residence-region:v1", - "purpose": "demo-routing", - "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1"}}] - }' -``` - -Note what is not in that body: the person. `requestNonce` is a caller -correlation value, echoed into the assertion and kept away from authorization, -sources, and audit. The bundle says where the subject -comes from instead. - -```yaml -authorityProfiles: - delegated-agent-v1: - kind: delegated - requesterTags: [demo-agent] - grants: - - requirement: urn:example:demo:requirement:residence-region:v1 - purpose: demo-routing - audienceFrom: authenticated-requester - subjects: - - role: subject - selectorProfile: demographics-v1 - valueOrigin: authenticated-context # <- the security property - valueClaims: - given_name: identity.given_name - family_name: identity.family_name - birth_date: identity.birth_date -``` - -Evidence answers with a signed assertion whose payload carries a coarse region -and an opaque subject binding. The person's name and their residence code are in -neither the request nor the answer: - -```json -{ - "supportsRequirement": "urn:example:demo:requirement:residence-region:v1", - "purpose": "demo-routing", - "audience": "https://scheduler.demo.invalid", - "subjects": [{"role": "subject", "binding": "urn:evidence:subject:v1_lARwiBg..."}], - "supportedValues": [ - {"providesValueFor": "urn:example:demo:concept:residence-region", "value": "REGION-NORTH"} - ] -} -``` - -### 4. The same token, pointed at somebody else - -```bash -curl -sS http://127.0.0.1:8080/v1/evidence \ - -H "Authorization: Bearer ${TOKEN}" \ - -H 'Content-Type: application/json' \ - -d '{ - "requestNonce": "<32 random bytes, base64url, unpadded>", - "requirement": "urn:example:demo:requirement:residence-region:v1", - "purpose": "demo-routing", - "subjects": [{"role": "subject", "selector": {"profile": "demographics-v1", - "values": {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"}}}] - }' -``` - -```json -{"type": "https://id.registrystack.org/problems/registry-evidence/request/selector_invalid", - "title": "Selector is invalid", "status": 400, - "detail": "selector does not match an available request profile", - "code": "request.selector_invalid", - "traceId": "0123456789abcdef0123456789abcdef"} -``` - -There is no request this token can make about Kofi Mensah. The refusal is for -carrying selector values at all, not for carrying the wrong ones, so a client -bug that puts the wrong person in the body cannot reach that person. - -## What this does and does not defend against - -It defends against a **buggy** client. A client that loops over the wrong list, -reuses a request object, or confuses two sessions cannot cross from one person -to another, because the subject is not a request parameter it controls. - -It does not defend against a **compromised** client. A client holding its own -signing key can ask Mint for a token naming a different subject, within the -fields its registration permits. Closing that would mean Mint resolving the -subject from a server-side grant record rather than from the caller's request, -which is a larger change and is not what this builds. - -Two further limits worth stating plainly: - -- Evidence confines an *actor-bearing* token to `kind: delegated` authority - profiles, but it does not conversely require an actor to reach one. An - undelegated token therefore matches this grant and is stopped when the subject - cannot be resolved, rather than at the authority match. Nothing leaks either - way, but the two are not interchangeable: were this grant to gain a subject - role whose values come from the request, an undelegated token would reach it. -- The registry source still receives the person's identifying details. Data - minimization here is about what the *caller* learns, not about what the source - is asked. - -## Why the demonstration needs a certificate - -Evidence requires the token issuer and its key set to be HTTPS, with no -exception for loopback, and Mint expects TLS to be terminated upstream. Rather -than work around that, `run.sh` issues a throwaway CA and puts a small TLS -terminator in front of Mint, exactly where your ingress would sit. The CA is -trusted only by the demonstration's own Evidence process, through `SSL_CERT_FILE`. - -## The same property, as a test - -Steps 3 and 4 are also asserted in -[`tests/delegated_subject_binding.rs`](../tests/delegated_subject_binding.rs), -which loads this same bundle and runs Evidence's own authorization decision over -a token minted by the real Mint router. The demonstration and the test cannot -drift: they share `evidence-bundle/`, and the test's constants must match it. diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai deleted file mode 100644 index 14c46b79f0..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source-prepare.rhai +++ /dev/null @@ -1,16 +0,0 @@ -fn prepare(selectors, context) { - let parameters = context["parameters"]; - let subject = selectors["subject"]; - #{ - query: [], - body: #{ - lookup: #{ - given_name: subject["values"]["given_name"], - family_name: subject["values"]["family_name"], - birth_date: subject["values"]["birth_date"] - }, - fields: parameters["requestedFields"], - limit: parameters["resultLimit"] - } - } -} diff --git a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai b/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai deleted file mode 100644 index 1647f9e1fe..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/adapters/demo-source.rhai +++ /dev/null @@ -1,13 +0,0 @@ -fn extract(source_response, context) { - let total = source_response["total"]; - if total == 0 { - if len(source_response) != 1 { throw("source_protocol_error"); } - return #{outcome: "no_match"}; - } - if total > 1 { return #{outcome: "ambiguous"}; } - let official_residence_code = get_path(source_response, "/official_residence_code"); - if is_missing(official_residence_code) { - return #{outcome: "match", facts: #{}}; - } - #{outcome: "match", facts: #{official_residence_code: official_residence_code}} -} diff --git a/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml b/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml deleted file mode 100644 index e7bfbbf26d..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/codelists/region-map.yaml +++ /dev/null @@ -1,6 +0,0 @@ -id: urn:example:demo:codelist:region-map -version: '2026-01' -entries: - R-101: REGION-NORTH - R-201: REGION-SOUTH -allowed_outputs: [REGION-NORTH, REGION-SOUTH] diff --git a/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai b/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai deleted file mode 100644 index ef3749040e..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/derivations/residence-region.rhai +++ /dev/null @@ -1,10 +0,0 @@ -fn derive(facts, selectors, evaluation_context) { - let mapped = codelist_lookup( - evaluation_context.codelists["region-map"], - required(facts.official_residence_code, "required_fact_missing") - ); - [#{ - concept_id: "urn:example:demo:concept:residence-region", - value: required(mapped, "unknown_controlled_code") - }] -} diff --git a/crates/registry-mint/demo/evidence-bundle/evidence.yaml b/crates/registry-mint/demo/evidence-bundle/evidence.yaml deleted file mode 100644 index 7b37176275..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/evidence.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# Demonstration Evidence bundle for delegated, subject-bound access. -# -# The one line that matters is `valueOrigin: authenticated-context` under -# `delegated-agent-v1`. It moves the subject out of the request body and into -# the access token: Evidence reads the selector from the claims listed in -# `valueClaims`, and refuses any request that carries selector values of its -# own. A token minted for one person therefore cannot be pointed at another. -# -# Synthetic identifiers only. Nothing here describes a real person or source. -version: 1 -assuranceProfile: local -service: {publicOrigin: http://127.0.0.1:8080, providerId: urn:example:demo:provider:evidence, trustDomain: urn:example:demo:trust-domain:delegation} -issuer: {id: urn:example:demo:issuer:authority} -authentication: - kind: oidc-access-token - # Mint's public origin. Evidence requires HTTPS for both the issuer and the - # key set, so the demonstration puts a TLS terminator in front of Mint exactly - # as a real deployment would. - issuer: https://localhost:8443 - audiences: [evidence.demo.invalid] - tokenTypes: [at+jwt] - algorithms: [ES256] - maximumTokenLifetimeSeconds: 300 - revokedKeyIds: [] - jwksUri: https://localhost:8443/.well-known/jwks.json - principalClaim: sub - requesterTagsClaim: evidence_tags - evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority - # The claim Mint writes the delegated agent into. Its presence is what - # confines a token to `kind: delegated` authority profiles. - actorClaim: evidence_actor -audit: {format: keyed-jsonl, hashSecretRef: secret:file/audit-hash-key, hashKeyVersion: 1, failClosed: true} -subjectBinding: {secretRef: secret:file/subject-binding-key, keyVersion: 1} -rateLimits: {requestsPerPrincipalPerMinute: 60, burstPerPrincipal: 10, failedSelectorAttemptsPerPrincipalAuthorityPerMinute: 10} -signing: {format: flattened-jws-json, algorithm: ES256, activePublicJwkFile: public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json, publishedPublicJwkFiles: [], revokedKeyIds: [], jwksPath: /.well-known/evidence/jwks.json, maximumAssertionValiditySeconds: 86400, verifierClockSkewSeconds: 30} -selectorProfiles: - demographics-v1: - maximumAggregateBytes: 420 - fields: - given_name: {type: string, minimumBytes: 1, maximumBytes: 200} - family_name: {type: string, minimumBytes: 1, maximumBytes: 200} - birth_date: {type: date} -sources: - demo-source: - transport: http-json - # The stand-in registry source the demonstration runs on loopback. Evidence - # permits plain HTTP only for a numeric loopback host. - baseUrl: http://127.0.0.1:8092 - posture: field-projected - authentication: {kind: static-authorization, tokenRef: secret:file/source-token} - request: - method: POST - path: /v1/facts - fixedHeaders: [{name: Accept, value: application/json}] - selectorInputs: - - role: subject - alternatives: [{profile: demographics-v1, fields: [given_name, family_name, birth_date]}] - prepareScript: adapters/demo-source-prepare.rhai - adapterParameters: {requestedFields: [official_residence_code], resultLimit: 2} - adapterParametersSchema: schemas/adapter-parameters.schema.yaml - preparationLimits: {query: forbidden, jsonBody: required, maximumJsonDepth: 8, maximumCollectionItems: 16, maximumStringBytes: 256, maximumNormalizedBytes: 4096} - projection: [/total, /official_residence_code] - redirects: deny - timeoutMilliseconds: 3000 - maximumResponseBytes: 65536 - concurrencyLimit: 8 - responseSchema: schemas/response.schema.yaml - extractScript: adapters/demo-source.rhai - factSchema: schemas/facts.schema.yaml -authorityProfiles: - delegated-agent-v1: - kind: delegated - requesterTags: [demo-agent] - grants: - - requirement: urn:example:demo:requirement:residence-region:v1 - purpose: demo-routing - audienceFrom: authenticated-requester - subjects: - - role: subject - selectorProfile: demographics-v1 - valueOrigin: authenticated-context - valueClaims: - given_name: identity.given_name - family_name: identity.family_name - birth_date: identity.birth_date -requirements: - - handle: residence-region - id: urn:example:demo:requirement:residence-region:v1 - kind: information-requirement - acquisition: - kind: single - source: demo-source - purposes: [demo-routing] - subjectRoles: [{role: subject, cardinality: one, selectorProfiles: [demographics-v1]}] - referenceFrameworks: [urn:example:demo:framework:residence-region:v1] - evidenceType: urn:example:demo:evidence-type:residence-region:v1 - validitySeconds: 86400 - derivation: {script: derivations/residence-region.rhai, parameters: {}} - concepts: - - handle: region - id: urn:example:demo:concept:residence-region - form: controlled-code - required: true - constraints: {codelist: codelists/region-map.yaml, codelistVersion: '2026-01', maximumBytes: 32} - fixtures: fixtures/cases.yaml - disclosureGuard: {families: [urn:example:demo:disclosure-family:residence-region]} - existenceDisclosure: collapse-unresolved diff --git a/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml b/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml deleted file mode 100644 index 303284e9f4..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/fixtures/cases.yaml +++ /dev/null @@ -1,31 +0,0 @@ -fixture: registry.mint.demo.delegation/v1 -synthetic_only: true -common: - observed_at: '2026-08-02T00:00:00Z' - selectors: - subject: - profile: demographics-v1 - values: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} - expectedRequestParts: - query: [] - body: - lookup: {given_name: Amara, family_name: Okafor, birth_date: '1998-04-02'} - fields: [official_residence_code] - limit: 2 - expectedTransport: - path: /v1/facts - fixedHeaders: [{name: Accept, value: application/json}] -cases: - - {id: positive, source: {total: 1, official_residence_code: R-101}, expected_value: REGION-NORTH, expected_lookup: match, derivation_runs: true, signed_success: true} - - {id: negative-unknown-code, source: {total: 1, official_residence_code: R-999}, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: negative-overly-precise-output, injected_derivation: [{concept_id: urn:example:demo:concept:residence-region, value: R-101}], expected: output-gate-rejection} - - {id: boundary-other-coarse-region, source: {total: 1, official_residence_code: R-201}, expected_value: REGION-SOUTH, expected_lookup: match, derivation_runs: true, signed_success: true} - - {id: missing-fact, source: {total: 1}, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: no-match, source: {total: 0}, expected_lookup: no_match, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: ambiguous, source: {total: 2}, expected_lookup: ambiguous, expected_public_problem: evidence.unavailable, derivation_runs: false, signed_success: false} - - {id: source-failure, source_failure: http-503, expected_public_problem: source.unavailable, signed_success: false} - - {id: anti-reconstruction, companion_bundle: geographic-overlap, expected: bundle-rejection} -privacy_expectation: - evidence_contains: [urn:example:demo:concept:residence-region, REGION-NORTH] - evidence_excludes: [official_residence_code, given_name, family_name, birth_date, R-101] - diagnostics_exclude: [Amara, Okafor, '1998-04-02', R-101, REGION-NORTH] diff --git a/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json b/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json deleted file mode 100644 index b331659f48..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/public-keys/_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo.jwk.json +++ /dev/null @@ -1 +0,0 @@ -{"alg":"ES256","crv":"P-256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo","kty":"EC","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU"} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml deleted file mode 100644 index 39fc579319..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/schemas/adapter-parameters.schema.yaml +++ /dev/null @@ -1,7 +0,0 @@ -type: object -additionalProperties: false -required: [requestedFields, resultLimit] -properties: - requestedFields: - const: [official_residence_code] - resultLimit: {const: 2} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml deleted file mode 100644 index d3e45d7b6c..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/schemas/facts.schema.yaml +++ /dev/null @@ -1,5 +0,0 @@ -type: object -additionalProperties: false -required: [official_residence_code] -properties: - official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml b/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml deleted file mode 100644 index 70d83308ff..0000000000 --- a/crates/registry-mint/demo/evidence-bundle/schemas/response.schema.yaml +++ /dev/null @@ -1,6 +0,0 @@ -type: object -additionalProperties: false -required: [total] -properties: - total: {type: integer, minimum: 0, maximum: 1000000} - official_residence_code: {type: string, minLength: 1, maxLength: 32} diff --git a/crates/registry-mint/demo/run.sh b/crates/registry-mint/demo/run.sh deleted file mode 100755 index 71de1e15bf..0000000000 --- a/crates/registry-mint/demo/run.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# Start a throwaway Mint and Evidence deployment on loopback, run the delegation -# walkthrough against them, then tear everything down. -# -# Nothing here is the demonstration. `walkthrough.py` is. -set -euo pipefail - -demo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -workspace="$(cd "${demo_dir}/../../.." && pwd)" -run_dir="${demo_dir}/.run" -log_dir="${run_dir}/logs" - -# The source's bearer token. Generated per run, exported to the two processes -# that need it, and never printed or passed as an argument. -DEMO_SOURCE_TOKEN="$(openssl rand -hex 24)" -export DEMO_SOURCE_TOKEN - -pids=() -cleanup() { - for pid in "${pids[@]:-}"; do - kill "${pid}" 2>/dev/null || true - done - wait 2>/dev/null || true -} -trap cleanup EXIT - -wait_for_port() { - local port="$1" name="$2" - for _ in $(seq 1 100); do - if nc -z 127.0.0.1 "${port}" 2>/dev/null; then - return 0 - fi - sleep 0.1 - done - printf 'error: %s never listened on port %s\n' "${name}" "${port}" >&2 - printf 'see %s/%s.log\n' "${log_dir}" "${name}" >&2 - return 1 -} - -printf '== building mint and evidence\n' -cargo build --locked --manifest-path "${workspace}/Cargo.toml" \ - -p registry-mint -p registry-evidence --bins >/dev/null - -printf '== provisioning a throwaway deployment in %s\n' "${run_dir}" -uv run --quiet "${demo_dir}/support/provision.py" "${run_dir}" "${demo_dir}/evidence-bundle" \ - >/dev/null -mkdir -p "${log_dir}" - -printf '== starting the stand-in registry source, Mint, its TLS front, and Evidence\n' -uv run --quiet "${demo_dir}/support/mock_source.py" 8092 >"${log_dir}/source.log" 2>&1 & -pids+=("$!") - -"${workspace}/target/debug/mint" serve --config "${run_dir}/mint/mint.yaml" \ - >"${log_dir}/mint.log" 2>&1 & -pids+=("$!") -wait_for_port 8090 mint - -uv run --quiet "${demo_dir}/support/tls_front.py" 8443 8090 \ - "${run_dir}/tls.pem" "${run_dir}/tls.key" >"${log_dir}/tls.log" 2>&1 & -pids+=("$!") - -# Evidence fetches Mint's key set over HTTPS. SSL_CERT_FILE is how the demo's -# private CA becomes trusted for this process, and only this process. -SSL_CERT_FILE="${run_dir}/ca.pem" \ - "${workspace}/target/debug/evidence" --runtime "${run_dir}/evidence/runtime.yaml" serve \ - >"${log_dir}/evidence.log" 2>&1 & -pids+=("$!") - -wait_for_port 8092 source -wait_for_port 8443 tls -wait_for_port 8080 evidence - -printf '\n' -uv run --quiet "${demo_dir}/walkthrough.py" "${run_dir}" diff --git a/crates/registry-mint/demo/support/key_material.py b/crates/registry-mint/demo/support/key_material.py deleted file mode 100755 index b839669abc..0000000000 --- a/crates/registry-mint/demo/support/key_material.py +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = ["cryptography>=42"] -# /// -"""Disposable local-development key material shared by Registry Mint demos.""" - -from __future__ import annotations - -import argparse -import base64 -import hashlib -import json -import os -import secrets -from pathlib import Path - -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec, ed25519 - - -def b64(raw: bytes) -> str: - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - -def ed25519_jwk(kid: str) -> tuple[dict, dict]: - """Return a disposable Ed25519 (private JWK, public JWK) pair.""" - private = ed25519.Ed25519PrivateKey.generate() - x = b64( - private.public_key().public_bytes( - serialization.Encoding.Raw, serialization.PublicFormat.Raw - ) - ) - d = b64( - private.private_bytes( - serialization.Encoding.Raw, - serialization.PrivateFormat.Raw, - serialization.NoEncryption(), - ) - ) - public_jwk = {"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x} - return {**public_jwk, "d": d}, public_jwk - - -def p256_jwk() -> tuple[dict, dict]: - """Return a disposable ES256 pair with an RFC 7638 key identifier.""" - private = ec.generate_private_key(ec.SECP256R1()) - numbers = private.private_numbers() - public_numbers = numbers.public_numbers - public_jwk = { - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "x": b64(public_numbers.x.to_bytes(32, "big")), - "y": b64(public_numbers.y.to_bytes(32, "big")), - } - thumbprint_members = { - member: public_jwk[member] for member in ("crv", "kty", "x", "y") - } - thumbprint = json.dumps( - thumbprint_members, sort_keys=True, separators=(",", ":") - ).encode() - public_jwk["kid"] = b64(hashlib.sha256(thumbprint).digest()) - private_jwk = { - **public_jwk, - "d": b64(numbers.private_value.to_bytes(32, "big")), - } - return private_jwk, public_jwk - - -def write(path: Path, text: str, mode: int = 0o644) -> Path: - """Write public local-development material.""" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - path.chmod(mode) - return path - - -def write_secret(path: Path, text: str) -> Path: - """Create or replace one secret without a wider intermediate mode.""" - path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(text) - path.chmod(0o600) - return path - - -def _new_output(path: Path) -> None: - if path.exists() or path.is_symlink(): - raise SystemExit(f"refusing to replace existing output: {path}") - - -def _generate_p256(private_out: Path, public_out: Path) -> None: - _new_output(private_out) - _new_output(public_out) - private, public = p256_jwk() - write_secret( - private_out, json.dumps(private, sort_keys=True, separators=(",", ":")) - ) - write(public_out, json.dumps(public, sort_keys=True, separators=(",", ":"))) - - -def _generate_secret(output: Path) -> None: - _new_output(output) - write_secret(output, secrets.token_hex(32)) - - -def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser() - commands = result.add_subparsers(dest="command", required=True) - p256 = commands.add_parser("p256") - p256.add_argument("--private-out", required=True, type=Path) - p256.add_argument("--public-out", required=True, type=Path) - secret = commands.add_parser("secret-hex") - secret.add_argument("--out", required=True, type=Path) - return result - - -def main() -> int: - args = parser().parse_args() - if args.command == "p256": - _generate_p256(args.private_out, args.public_out) - elif args.command == "secret-hex": - _generate_secret(args.out) - else: # pragma: no cover - raise AssertionError(args.command) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/crates/registry-mint/demo/support/mock_source.py b/crates/registry-mint/demo/support/mock_source.py deleted file mode 100644 index 8dada5bd80..0000000000 --- a/crates/registry-mint/demo/support/mock_source.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 -"""A stand-in registry source for the delegation demonstration. - -Deployment plumbing, not part of the security story. Evidence has to call -*something* to answer a requirement; this is the smallest thing that answers. - -It serves one route, `POST /v1/facts`, and answers from a fixed synthetic table. -Everything it knows is invented. -""" - -import json -import os -import sys -from http.server import BaseHTTPRequestHandler, HTTPServer - -# Synthetic people, synthetic residence codes. The bundle's codelist maps -# R-101 to REGION-NORTH and R-201 to REGION-SOUTH. -RECORDS = { - ("Amara", "Okafor", "1998-04-02"): "R-101", - ("Kofi", "Mensah", "1971-11-30"): "R-201", -} - -EXPECTED_BEARER = os.environ["DEMO_SOURCE_TOKEN"] - - -class Handler(BaseHTTPRequestHandler): - def do_POST(self): - if self.path != "/v1/facts": - return self.reply(404, {"error": "not_found"}) - if self.headers.get("Authorization") != f"Bearer {EXPECTED_BEARER}": - return self.reply(401, {"error": "unauthorized"}) - - length = int(self.headers.get("Content-Length", "0")) - body = json.loads(self.rfile.read(length) or b"{}") - lookup = body.get("lookup", {}) - key = ( - lookup.get("given_name"), - lookup.get("family_name"), - lookup.get("birth_date"), - ) - - # Note what the source receives: the person's identifying details, and - # nothing about the requirement, the purpose, or the caller. - print(f"source <- lookup for {key[0]} {key[1]}", file=sys.stderr, flush=True) - - code = RECORDS.get(key) - if code is None: - return self.reply(200, {"total": 0}) - return self.reply(200, {"total": 1, "official_residence_code": code}) - - def reply(self, status, payload): - encoded = json.dumps(payload).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(encoded))) - self.end_headers() - self.wfile.write(encoded) - - def log_message(self, format, *args): # noqa: A002 - the base class names it - pass # the one line printed in do_POST is the whole log we want - - -if __name__ == "__main__": - port = int(sys.argv[1]) - HTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/crates/registry-mint/demo/support/provision.py b/crates/registry-mint/demo/support/provision.py deleted file mode 100644 index e9d4da9498..0000000000 --- a/crates/registry-mint/demo/support/provision.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = ["cryptography>=42"] -# /// -"""Lay out a throwaway deployment of Mint and Evidence for the demonstration. - -Deployment plumbing, not part of the security story: keys, certificates, -configuration files, and file permissions. The walkthrough in `walkthrough.py` -is the part worth reading. - -Everything this writes is disposable and local. The keys are generated fresh on -every run and are worthless outside this directory. -""" - -import datetime as dt -import json -import os -import secrets -import shutil -import stat -import sys -from pathlib import Path - -from cryptography import x509 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519 -from cryptography.x509.oid import NameOID - -from key_material import ed25519_jwk, p256_jwk, write, write_secret - -MINT_PORT = 8090 -TLS_PORT = 8443 -EVIDENCE_PORT = 8080 -SOURCE_PORT = 8092 - -MINT_ORIGIN = f"https://localhost:{TLS_PORT}" -AGENT = "urn:example:demo:agent:appointment-scheduler" - - -def issue_tls_certificate(root: Path) -> None: - """A private CA and one `localhost` server certificate. - - Evidence insists the token issuer and its key set be HTTPS, with no - exception for loopback. That is the right default and the demonstration - respects it rather than working around it. - """ - now = dt.datetime.now(dt.timezone.utc) - ca_key = ed25519.Ed25519PrivateKey.generate() - ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "registry-stack demo CA")]) - ca_certificate = ( - x509.CertificateBuilder() - .subject_name(ca_name) - .issuer_name(ca_name) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - dt.timedelta(minutes=5)) - .not_valid_after(now + dt.timedelta(days=1)) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .sign(ca_key, None) - ) - - server_key = ed25519.Ed25519PrivateKey.generate() - server_certificate = ( - x509.CertificateBuilder() - .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])) - .issuer_name(ca_name) - .public_key(server_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - dt.timedelta(minutes=5)) - .not_valid_after(now + dt.timedelta(days=1)) - .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) - .add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) - .sign(ca_key, None) - ) - - pem = serialization.Encoding.PEM - write(root / "ca.pem", ca_certificate.public_bytes(pem).decode()) - write(root / "tls.pem", server_certificate.public_bytes(pem).decode()) - write_secret( - root / "tls.key", - server_key.private_bytes( - pem, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() - ).decode(), - ) - - -def provision_mint(root: Path) -> None: - mint = root / "mint" - signing_private, signing_public = p256_jwk() - write_secret(mint / "secrets/signing.jwk", json.dumps(signing_private)) - public_file = f"{signing_public['kid']}.jwk.json" - write(mint / f"public-keys/{public_file}", json.dumps(signing_public)) - write_secret(mint / "secrets/audit-hmac-key", secrets.token_hex(32)) - - for client_id in ("scheduler", "service-desk"): - private, public = ed25519_jwk(f"{client_id}-key-1") - write_secret(root / f"client-keys/{client_id}.jwk", json.dumps(private)) - - # `scheduler` is the delegated caller. Its registration is the whole - # authorization decision Mint makes: which agents it may act as, and - # which selector fields it may bind, at which claim paths. - delegation = ( - "delegation:\n" - f" actors: [{AGENT}]\n" - " subjectClaims:\n" - " given_name: identity.given_name\n" - " family_name: identity.family_name\n" - " birth_date: identity.birth_date\n" - if client_id == "scheduler" - else "" - ) - write( - mint / f"clients/{client_id}.yaml", - f"clientId: {client_id}\n" - f"principal: urn:example:demo:principal:{client_id}\n" - f"evidenceAudience: https://{client_id}.demo.invalid\n" - "requesterTags: [demo-agent]\n" - f"keys: [{json.dumps(public)}]\n" + delegation, - ) - - write( - mint / "mint.yaml", - f"""version: 1 -validationMode: supervised-local-development -issuer: {MINT_ORIGIN} -listener: {{address: 127.0.0.1, port: {MINT_PORT}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {mint / "secrets"}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence.demo.invalid] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority - actor: evidence_actor -clientAssertion: - audience: {MINT_ORIGIN}/token - algorithms: [EdDSA] -clients: - directory: clients -""", - ) - - -def provision_evidence(root: Path, bundle_source: Path) -> None: - evidence = root / "evidence" - - # Evidence refuses a bundle it could write to, so the copy is frozen. - bundle = evidence / "bundle" - shutil.copytree(bundle_source, bundle) - for path in sorted(bundle.rglob("*"), reverse=True): - path.chmod(0o555 if path.is_dir() else 0o444) - bundle.chmod(0o555) - - # `secretProviders.file.root` in the runtime file below. The name stays - # clear of the word "secret" because this is a directory path, written into - # a world-readable configuration file, and a scanner that reads names alone - # cannot tell it apart from the material inside it. - provider_root = evidence / "secrets" - provider_root.mkdir(parents=True, exist_ok=True) - provider_root.chmod(0o700) # Evidence refuses a group- or world-readable root - - evidence_signing_private = { - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "kid": "_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo", - "x": "3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4", - "y": "GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU", - "d": "MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4", - } - write_secret( - provider_root / "evidence-signing", json.dumps(evidence_signing_private) - ) - write_secret(provider_root / "audit-hash-key", secrets.token_hex(32)) - write_secret(provider_root / "subject-binding-key", secrets.token_hex(32)) - write_secret(provider_root / "source-token", os.environ["DEMO_SOURCE_TOKEN"]) - - (evidence / "audit").mkdir(parents=True, exist_ok=True) - write( - evidence / "runtime.yaml", - f"""version: 1 -bundleDirectory: {bundle} -listener: - bindHost: 127.0.0.1 - port: {EVIDENCE_PORT} - tlsTermination: operator-controlled-upstream - trustProxyIdentityHeaders: false - maximumRequestBytes: 65536 - maximumConcurrentRequests: 64 - requestTimeoutMilliseconds: 10000 - shutdownGraceMilliseconds: 5000 -secretProviders: - file: - root: {provider_root} -signer: - kind: local-jwk - privateKeyRef: secret:file/evidence-signing -auditStorage: - path: {evidence / "audit/evidence.jsonl"} - maximumFileBytes: 1073741824 -outboundTls: - systemRoots: true - trustProfiles: {{}} -""", - 0o444, # Evidence refuses a runtime file it could write to - ) - - -if __name__ == "__main__": - root = Path(sys.argv[1]).resolve() - bundle_source = Path(sys.argv[2]).resolve() - if root.exists(): - # Frozen directories need their write bit back before removal. - for path in sorted(root.rglob("*"), reverse=True): - path.chmod(path.stat().st_mode | stat.S_IWUSR) - shutil.rmtree(root) - root.mkdir(parents=True) - - issue_tls_certificate(root) - provision_mint(root) - provision_evidence(root, bundle_source) - print(f"provisioned {root}") diff --git a/crates/registry-mint/demo/support/test_provision.py b/crates/registry-mint/demo/support/test_provision.py deleted file mode 100644 index 58b07863fe..0000000000 --- a/crates/registry-mint/demo/support/test_provision.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for the demonstration's provisioning plumbing. - -Only the part that matters outside the demonstration: a private key or a bearer -token must not exist on disk readable by anyone else, not even for the moment -between creating the file and narrowing it. - -Run with `uv run --with cryptography --no-project python -m unittest -crates/registry-mint/demo/support/test_provision.py`; the tests skip where -`cryptography` is unavailable. -""" - -import importlib.util -import base64 -import hashlib -import json -import os -import stat -import sys -import tempfile -import unittest -from pathlib import Path - -SUPPORT = Path(__file__).resolve().parent - - -def load_module(): - sys.path.insert(0, str(SUPPORT)) - specification = importlib.util.spec_from_file_location( - "demo_provision", SUPPORT / "provision.py" - ) - module = importlib.util.module_from_spec(specification) - try: - specification.loader.exec_module(module) - except ImportError as error: # pragma: no cover - depends on the environment - raise unittest.SkipTest(f"provision.py needs {error.name}") from None - finally: - sys.path.remove(str(SUPPORT)) - return module - - -try: - provision = load_module() -except unittest.SkipTest: # pragma: no cover - depends on the environment - provision = None - - -@unittest.skipIf(provision is None, "cryptography is not installed") -class SecretFileModeTests(unittest.TestCase): - def setUp(self): - self.root = Path(tempfile.mkdtemp()) - previous = os.umask(0) - self.addCleanup(os.umask, previous) - - def test_a_secret_is_never_wider_than_owner_read_write(self): - path = provision.write_secret(self.root / "signing.jwk", "not-a-real-key") - - self.assertEqual(0o600, stat.S_IMODE(path.stat().st_mode)) - self.assertEqual("not-a-real-key", path.read_text()) - - def test_the_file_is_created_at_its_final_mode_not_narrowed_afterwards(self): - modes = [] - real_open = os.open - - def spy(path, flags, mode, **rest): - modes.append(mode) - return real_open(path, flags, mode, **rest) - - os.open = spy - self.addCleanup(setattr, os, "open", real_open) - provision.write_secret(self.root / "audit-hash-key", "not-a-real-key") - - self.assertEqual([0o600], modes) - - def test_ordinary_files_keep_their_readable_mode(self): - path = provision.write(self.root / "ca.pem", "not-a-real-certificate") - - self.assertEqual(0o644, stat.S_IMODE(path.stat().st_mode)) - - def test_service_key_is_es256_with_an_rfc7638_identifier(self): - private, public = provision.p256_jwk() - members = {name: public[name] for name in ("crv", "kty", "x", "y")} - digest = hashlib.sha256( - json.dumps(members, sort_keys=True, separators=(",", ":")).encode() - ).digest() - expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() - - self.assertEqual("ES256", public["alg"]) - self.assertEqual("EC", public["kty"]) - self.assertEqual("P-256", public["crv"]) - self.assertEqual(expected, public["kid"]) - self.assertEqual(expected, private["kid"]) - self.assertIn("d", private) - self.assertNotIn("d", public) - - def test_shared_key_helper_creates_owner_only_secret_and_refuses_replacement(self): - output = self.root / "secret" - key_material = sys.modules["key_material"] - key_material._generate_secret(output) - - self.assertEqual(0o600, stat.S_IMODE(output.stat().st_mode)) - self.assertEqual(64, len(output.read_text())) - with self.assertRaises(SystemExit): - key_material._generate_secret(output) - - -if __name__ == "__main__": - sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-mint/demo/support/test_tls_front.py b/crates/registry-mint/demo/support/test_tls_front.py deleted file mode 100644 index 26b7e49da2..0000000000 --- a/crates/registry-mint/demo/support/test_tls_front.py +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env python3 -"""Tests for the demonstration's TLS front. - -The front is deployment plumbing, but it is plumbing in a public repository, so -the two properties worth pinning are the ones a real ingress would be judged on: -it forwards only the routes the deployment declares, and it never relays a -header it cannot write safely. -""" - -import importlib.util -import shutil -import socket -import ssl -import subprocess -import sys -import tempfile -import threading -import unittest -from http.server import ThreadingHTTPServer -from pathlib import Path - -SUPPORT = Path(__file__).resolve().parent - - -def load_module(): - specification = importlib.util.spec_from_file_location( - "demo_tls_front", SUPPORT / "tls_front.py" - ) - module = importlib.util.module_from_spec(specification) - specification.loader.exec_module(module) - return module - - -tls_front = load_module() - - -class RouteAllowlistTests(unittest.TestCase): - def test_declared_routes_forward_to_a_constant_path(self): - self.assertEqual("/token", tls_front.route_for("POST", "/token")) - self.assertEqual( - "/.well-known/jwks.json", - tls_front.route_for("GET", "/.well-known/jwks.json"), - ) - - def test_every_forwarded_path_is_one_this_file_declares(self): - for method in ("GET", "POST", "PUT"): - for path in ("/token", "/.well-known/jwks.json", "/anything"): - forwarded = tls_front.route_for(method, path) - if forwarded is not None: - self.assertIn((method, forwarded), tls_front.ROUTES) - - def test_undeclared_routes_are_refused(self): - for method, path in ( - ("GET", "/token"), # right path, wrong method - ("POST", "/.well-known/jwks.json"), - ("GET", "/health"), - ("GET", "/"), - ("GET", "/token/../admin"), - ("GET", "http://elsewhere.invalid/token"), # absolute-form request line - ("GET", "//elsewhere.invalid/token"), - ("GET", "/.well-known/jwks.json?x=1"), - ): - with self.subTest(method=method, path=path): - self.assertIsNone(tls_front.route_for(method, path)) - - -class HeaderValidationTests(unittest.TestCase): - def test_ordinary_headers_are_well_formed(self): - self.assertTrue(tls_front.well_formed("Content-Type", "application/json")) - - def test_control_characters_are_rejected_in_name_or_value(self): - for name, value in ( - ("X-Demo", "ok\r\nInjected: yes"), - ("X-Demo", "ok\nInjected: yes"), - ("X-Demo", "ok\r"), - ("X-Demo", "ok\x00"), - ("X-Demo\r\nInjected", "ok"), - ("X-Demo\n", "ok"), - ): - with self.subTest(name=name, value=value): - self.assertFalse(tls_front.well_formed(name, value)) - - -class TlsContextTests(unittest.TestCase): - def test_the_listener_refuses_anything_below_tls_1_2(self): - certificate, key = write_self_signed() - context = tls_front.tls_context(certificate, key) - self.assertEqual(ssl.TLSVersion.TLSv1_2, context.minimum_version) - - -class ForwardingTests(unittest.TestCase): - """End to end over real sockets, with a stub standing in for Mint.""" - - def setUp(self): - self.upstream = StubUpstream() - self.upstream.start() - self.addCleanup(self.upstream.stop) - - tls_front.UPSTREAM_PORT = self.upstream.port - self.front = ThreadingHTTPServer(("127.0.0.1", 0), tls_front.Handler) - threading.Thread(target=self.front.serve_forever, daemon=True).start() - self.addCleanup(self.front.server_close) # cleanups run last-registered first - self.addCleanup(self.front.shutdown) - self.port = self.front.server_address[1] - - def request(self, raw: bytes) -> bytes: - # `Connection: close` so the read below ends at end of message rather - # than waiting out the keep-alive. - raw = raw[: -len(b"\r\n\r\n")] + b"\r\nConnection: close\r\n\r\n" - with socket.create_connection(("127.0.0.1", self.port), timeout=5) as client: - client.sendall(raw) - chunks = [] - while True: - chunk = client.recv(4096) - if not chunk: - return b"".join(chunks) - chunks.append(chunk) - - def test_a_declared_route_round_trips(self): - self.upstream.reply = ( - b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" - b"Content-Length: 2\r\n\r\n{}" - ) - response = self.request( - b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n\r\n" - ) - self.assertIn(b"200 OK", response) - self.assertIn(b"{}", response) - self.assertEqual(["/.well-known/jwks.json"], self.upstream.seen) - - def test_an_undeclared_route_never_reaches_the_upstream(self): - response = self.request(b"GET /health HTTP/1.1\r\nHost: localhost\r\n\r\n") - self.assertIn(b"404", response) - self.assertEqual([], self.upstream.seen) - - def test_a_request_header_carrying_a_control_character_is_refused(self): - response = self.request( - b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n" - b"X-Demo: first\r\n\tsecond\r\n\r\n" - ) - self.assertIn(b"400", response) - self.assertEqual([], self.upstream.seen) - - def test_an_upstream_header_that_cannot_be_written_safely_is_not_relayed(self): - # An obs-folded header: `http.client` hands this back with the newline - # still in the value, and writing it out verbatim would split the - # response. - self.upstream.reply = ( - b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n" - b"X-Demo: first\r\n\tsecond\r\n\r\n{}" - ) - response = self.request( - b"GET /.well-known/jwks.json HTTP/1.1\r\nHost: localhost\r\n\r\n" - ) - self.assertIn(b"502", response) - self.assertNotIn(b"second", response) - - -class StubUpstream: - """A raw socket server so a test can send bytes `http.server` would refuse.""" - - def __init__(self): - self.reply = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" - self.seen: list[str] = [] - self.socket = socket.socket() - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.socket.bind(("127.0.0.1", 0)) - self.socket.listen(8) - self.port = self.socket.getsockname()[1] - self.running = True - - def start(self): - threading.Thread(target=self.serve, daemon=True).start() - - def serve(self): - while self.running: - try: - connection, _ = self.socket.accept() - except OSError: - return - with connection: - connection.settimeout(5) - try: - request = connection.recv(65536) - except OSError: - continue - if not request: - continue - self.seen.append(request.split(b" ")[1].decode()) - connection.sendall(self.reply) - - def stop(self): - self.running = False - self.socket.close() - - -def write_self_signed() -> tuple[str, str]: - """A throwaway certificate and key, via `openssl`. - - The demonstration's own provisioning uses `cryptography`, but nothing in - this repository's continuous integration installs it, and a test that skips - is a test that does not hold. `openssl` is already what `run.sh` reaches for - and is present wherever this suite runs. - """ - openssl = shutil.which("openssl") - if openssl is None: # pragma: no cover - depends on the environment - raise unittest.SkipTest("openssl is not installed") - - directory = Path(tempfile.mkdtemp()) - certificate_path = directory / "tls.pem" - key_path = directory / "tls.key" - subprocess.run( - [ - openssl, "req", "-x509", "-newkey", "ed25519", "-noenc", - "-days", "1", "-subj", "/CN=localhost", - "-keyout", str(key_path), "-out", str(certificate_path), - ], - check=True, - capture_output=True, - ) - return str(certificate_path), str(key_path) - - -if __name__ == "__main__": - sys.exit(0 if unittest.main(exit=False).result.wasSuccessful() else 1) diff --git a/crates/registry-mint/demo/support/tls_front.py b/crates/registry-mint/demo/support/tls_front.py deleted file mode 100644 index 242d7a74e6..0000000000 --- a/crates/registry-mint/demo/support/tls_front.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""A TLS terminator in front of Mint, for the delegation demonstration. - -Deployment plumbing, not part of the security story. Mint speaks plain HTTP and -expects an operator-controlled TLS front, and Evidence refuses a non-HTTPS token -issuer, so the demonstration supplies one. In production this is your ingress. - -It forwards to Mint on loopback and adds nothing. What it does do is what an -ingress is expected to do: publish only the routes the deployment declares, and -refuse anything it cannot pass on without changing the shape of a message. -""" - -import http.client -import json -import ssl -import sys -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -# The only routes this deployment puts through the front: Mint's key set, which -# Evidence fetches, and its token endpoint, which the client posts to. Mint's -# other routes stay on loopback. -ROUTES = ( - ("GET", "/.well-known/jwks.json"), - ("POST", "/token"), -) - -# Hop-by-hop headers belong to one connection and are not relayed onto the next. -NOT_RELAYED = ("transfer-encoding", "connection", "content-length") - -# A header carrying one of these would end the header block early, so the front -# refuses the message rather than passing on something it cannot write intact. -CONTROL_CHARACTERS = ("\r", "\n", "\x00") - -UPSTREAM_PORT = None - - -def route_for(method: str, path: str) -> str | None: - """The upstream path for a request, or `None` if the front does not serve it. - - The value returned is one of this file's own literals, never the caller's - request line: the target of the upstream request is fixed here and cannot be - steered from outside. - """ - for allowed_method, allowed_path in ROUTES: - if method == allowed_method and path == allowed_path: - return allowed_path - return None - - -def well_formed(name: str, value: str) -> bool: - """Whether a header can be relayed without changing the message's framing.""" - return not any( - character in name or character in value for character in CONTROL_CHARACTERS - ) - - -def tls_context(certificate: str, key: str) -> ssl.SSLContext: - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - # An ingress sets its own floor rather than inheriting whatever the runtime - # happens to allow. - context.minimum_version = ssl.TLSVersion.TLSv1_2 - context.load_cert_chain(certificate, key) - return context - - -class Handler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def do_GET(self): - self.forward("GET", None) - - def do_POST(self): - length = int(self.headers.get("Content-Length", "0")) - self.forward("POST", self.rfile.read(length)) - - def forward(self, method, body): - target = route_for(method, self.path) - if target is None: - self.refuse(404, "no such route") - return - - headers = { - name: value - for name, value in self.headers.items() - if name.lower() not in ("host", "connection") - } - if not all(well_formed(name, value) for name, value in headers.items()): - self.refuse(400, "request header carried a control character") - return - - upstream = http.client.HTTPConnection("127.0.0.1", UPSTREAM_PORT, timeout=10) - try: - upstream.request(method, target, body=body, headers=headers) - response = upstream.getresponse() - payload = response.read() - status = response.status - relayed = [ - (name, value) - for name, value in response.getheaders() - if name.lower() not in NOT_RELAYED - ] - finally: - upstream.close() - - if not all(well_formed(name, value) for name, value in relayed): - self.refuse(502, "upstream header carried a control character") - return - - self.send_response(status) - for name, value in relayed: - self.send_header(name, value) - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def refuse(self, status, reason): - payload = json.dumps({"error": reason}).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - - def log_message(self, format, *args): # noqa: A002 - the base class names it - pass - - -if __name__ == "__main__": - listen_port, UPSTREAM_PORT, certificate, key = ( - int(sys.argv[1]), - int(sys.argv[2]), - sys.argv[3], - sys.argv[4], - ) - server = ThreadingHTTPServer(("127.0.0.1", listen_port), Handler) - server.socket = tls_context(certificate, key).wrap_socket( - server.socket, server_side=True - ) - server.serve_forever() diff --git a/crates/registry-mint/demo/walkthrough.py b/crates/registry-mint/demo/walkthrough.py deleted file mode 100644 index 81ea29b85d..0000000000 --- a/crates/registry-mint/demo/walkthrough.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = ["cryptography>=42", "requests>=2.31"] -# /// -"""Delegated, subject-bound access, end to end. - -An agent needs to know which region one person lives in. It must not be able to -learn that about anyone else, even if the agent's own code is wrong. - - 1. The client signs a request for a token, naming the agent it is acting as - and the person it is acting for. - 2. Mint checks that request against the client's registration and issues a - token carrying both. - 3. The client asks Evidence for evidence, and does not name the person. - 4. The client tries to name a different person, and cannot. - -Two further steps show the refusals that hold that shape up: what Mint will not -issue, and what a token without a delegation cannot reach. - -Every request below is printed before it is sent. Run it with: - - crates/registry-mint/demo/run.sh -""" - -import base64 -import json -import secrets -import sys -from pathlib import Path - -import requests -from cryptography.hazmat.primitives.asymmetric import ed25519 - -MINT = "https://localhost:8443" -EVIDENCE = "http://127.0.0.1:8080" - -REQUIREMENT = "urn:example:demo:requirement:residence-region:v1" -PURPOSE = "demo-routing" -AGENT = "urn:example:demo:agent:appointment-scheduler" - -AMARA = {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"} -KOFI = {"given_name": "Kofi", "family_name": "Mensah", "birth_date": "1971-11-30"} - - -# -------------------------------------------------------------------------- -# Step 1: the client assertion. -# -# The client authenticates to Mint with a JWT it signs with its own key -# (RFC 7523 `private_key_jwt`). There is no shared secret to leak, and Mint -# holds only public keys. -# -# The delegation request rides *inside* that JWT, in `on_behalf_of`. That -# placement is the point: the actor and the subject are covered by the client's -# signature, so nothing between the client and Mint can alter who the token is -# for. -# -------------------------------------------------------------------------- - - -def build_client_assertion(client_id, private_key, jti, on_behalf_of=None): - claims = { - "iss": client_id, - "sub": client_id, - "aud": f"{MINT}/token", - "iat": now(), - "exp": now() + 120, - "jti": jti, # Mint refuses a second assertion with the same jti - } - if on_behalf_of is not None: - claims["on_behalf_of"] = on_behalf_of - - announce("the client assertion the client is about to sign", claims) - return sign_jwt({"alg": "EdDSA", "typ": "JWT", "kid": private_key["kid"]}, claims, - private_key) - - -# -------------------------------------------------------------------------- -# Step 2: the token request. -# -# Mint verifies the signature against the keys registered for this client, then -# checks the delegation request against the same registration: is this an actor -# the client may act as, and are these exactly the selector fields it may bind? -# Neither answer comes from the request. -# -------------------------------------------------------------------------- - - -def request_token(assertion): - form = { - "grant_type": "client_credentials", - "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - "client_assertion": assertion, - } - announce(f"POST {MINT}/token", {**form, "client_assertion": ""}) - return requests.post(f"{MINT}/token", data=form, verify=CA, timeout=10) - - -# -------------------------------------------------------------------------- -# Step 3: the evidence request. -# -# Note what is *not* in this body: the person. The bundle declares the subject's -# `valueOrigin` as `authenticated-context`, so Evidence reads the selector out -# of the token's claims and refuses to read it from the request. -# -------------------------------------------------------------------------- - - -def request_evidence(token, subject_values=None): - selector = {"profile": "demographics-v1"} - if subject_values is not None: - selector["values"] = subject_values - body = { - # A caller-generated correlation value. Evidence echoes it into the - # assertion and keeps it away from authorization, sources, and audit. - "requestNonce": request_nonce(), - "requirement": REQUIREMENT, - "purpose": PURPOSE, - "subjects": [{"role": "subject", "selector": selector}], - } - announce(f"POST {EVIDENCE}/v1/evidence", body, header="Authorization: Bearer ") - return requests.post( - f"{EVIDENCE}/v1/evidence", - json=body, - headers={"Authorization": f"Bearer {token}"}, - timeout=30, - ) - - -def main(run_dir): - global CA - CA = str(run_dir / "ca.pem") - scheduler = load_jwk(run_dir / "client-keys/scheduler.jwk") - service_desk = load_jwk(run_dir / "client-keys/service-desk.jwk") - - heading("1. The client asks Mint for a token to act for one person") - assertion = build_client_assertion( - "scheduler", - scheduler, - "demo-1", - on_behalf_of={"actor": AGENT, "subject": AMARA}, - ) - response = request_token(assertion) - expect(response, 200) - token = response.json()["access_token"] - - heading("2. What Mint put in the token") - claims = decode_jwt_claims(token) - show(claims) - note( - "`evidence_actor` says who is acting. `identity.*` says who they are acting for.", - "Both were checked against the client's registration, not taken on trust.", - ) - - heading("3. The client asks Evidence for evidence, naming no one") - response = request_evidence(token) - expect(response, 200) - show(decode_evidence(response.json())) - note( - "Evidence resolved the subject from the token, called the source, and", - "returned a coarse region. The person's name and their residence code", - "are in neither the request nor the answer. The subject appears only as", - "an opaque binding that cannot be reversed into a name.", - ) - - heading("4. The same token, pointed at somebody else") - response = request_evidence(token, subject_values=KOFI) - expect(response, 400) - show(response.json()) - note( - "This is the containment. A bug in the client that puts the wrong person", - "in the request body does not reach that person: Evidence refuses the", - "request for carrying selector values at all, not for carrying the wrong", - "ones. There is no request this token can make about Kofi Mensah.", - ) - - heading("5. Mint refuses what it was not asked to allow") - for jti, description, client_id, key, on_behalf_of in ( - ( - "demo-wrong-actor", - "an actor this client may not act as", - "scheduler", - scheduler, - {"actor": "urn:example:demo:agent:someone-else", "subject": AMARA}, - ), - ( - "demo-undelegated", - "a client with no delegation in its registration", - "service-desk", - service_desk, - {"actor": AGENT, "subject": AMARA}, - ), - ( - "demo-extra-field", - "a subject carrying a field the registration does not bind", - "scheduler", - scheduler, - {"actor": AGENT, "subject": {**AMARA, "national_id": "synthetic-1"}}, - ), - ): - response = request_token( - build_client_assertion(client_id, key, jti, on_behalf_of=on_behalf_of) - ) - expect(response, 401) - print(f" refused: {description} -> {response.status_code} {response.json()}\n") - - heading("6. And an undelegated token cannot use the delegated grant") - response = request_token(build_client_assertion("service-desk", service_desk, "demo-plain")) - expect(response, 200) - plain_token = response.json()["access_token"] - note( - "This token is valid and carries the same requester tag. It simply has no", - "`evidence_actor` and no `identity.*`, so there is no subject to resolve.", - ) - response = request_evidence(plain_token) - expect(response, 400) - show(response.json()) - note( - "Worth being precise about the shape of this refusal. Evidence confines", - "an actor-bearing token to `kind: delegated` authority profiles, but it", - "does not require an actor to reach one. So this token matches the grant", - "and is stopped when the subject cannot be resolved, rather than at the", - "authority match. Nothing leaks either way.", - ) - - print("\nAll six steps behaved as described.") - - -# -------------------------------------------------------------------------- -# Below here is only formatting and JWT mechanics. Nothing decides anything. -# -------------------------------------------------------------------------- - - -def now(): - import time - - return int(time.time()) - - -def b64url(raw: bytes) -> str: - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - -def request_nonce() -> str: - """32 random bytes, base64url without padding. Evidence rejects anything else.""" - return b64url(secrets.token_bytes(32)) - - -def unb64url(text: str) -> bytes: - return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) - - -def load_jwk(path: Path) -> dict: - return json.loads(path.read_text()) - - -def sign_jwt(header: dict, claims: dict, private_jwk: dict) -> str: - key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"])) - signing_input = ".".join( - b64url(json.dumps(part, separators=(",", ":")).encode()) for part in (header, claims) - ) - return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" - - -def decode_jwt_claims(token: str) -> dict: - """Read the claims without verifying. Evidence verifies; this only shows.""" - return json.loads(unb64url(token.split(".")[1])) - - -def decode_evidence(assertion: dict) -> dict: - """Show the signed evidence assertion's payload rather than its base64. - - A verifier would check the signature over `protected` and `payload` against - Evidence's published key set. This only makes the answer legible. - """ - return { - "protected": json.loads(unb64url(assertion["protected"])), - "payload": json.loads(unb64url(assertion["payload"])), - "signature": assertion["signature"][:16] + "...", - } - - -def heading(text): - print(f"\n{'=' * 76}\n{text}\n{'=' * 76}") - - -def announce(what, payload, header=None): - print(f"\n {what}") - if header: - print(f" {header}") - for line in json.dumps(payload, indent=2).splitlines(): - print(f" {line}") - print() - - -def show(payload): - for line in json.dumps(payload, indent=2).splitlines(): - print(f" {line}") - - -def note(*lines): - print() - for line in lines: - print(f" -> {line}") - - -def expect(response, status): - if response.status_code != status: - print(f"\nunexpected {response.status_code}: {response.text}", file=sys.stderr) - raise SystemExit(1) - - -if __name__ == "__main__": - main(Path(sys.argv[1]).resolve()) diff --git a/crates/registry-mint/src/assertion.rs b/crates/registry-mint/src/assertion.rs deleted file mode 100644 index 486578dff7..0000000000 --- a/crates/registry-mint/src/assertion.rs +++ /dev/null @@ -1,1164 +0,0 @@ -//! RFC 7523 `private_key_jwt` client authentication. -//! -//! The single most important property in this module is that an assertion is -//! verified against **only the keys registered for the client it claims to be**. -//! -//! The alternative, pooling every client key into one JWK set, is what makes -//! distributing signing keys unsafe: key selection happens by `kid`, which the -//! signer chooses, and nothing downstream re-checks which key was used against -//! the claims that were signed. In a pooled set, client A signs with A's key, -//! writes `iss: client-b`, and verification succeeds. Selecting the key set by -//! the asserted client id *before* verifying removes that move entirely: A's -//! key simply is not in B's set, so the signature fails. -//! -//! Everything else here is bounding: strict structural preflight, an audience -//! bound to this endpoint, a bounded assertion lifetime, and single-use `jti`. - -use std::{collections::BTreeMap, sync::Arc, time::Duration}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_canonical_json::parse_json_strict; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; -use serde_json::Value; - -use crate::{ - clients::{ClientRegistry, Delegation, RegisteredClient}, - config::ClientAssertionConfig, - error::TokenError, - replay::{ReplayCache, ReplayError}, - ON_BEHALF_OF_CLAIM, -}; - -/// Bounds chosen so a hostile caller cannot make Mint allocate before any -/// signature has been checked. -const MAX_ASSERTION_BYTES: usize = 16 * 1024; -const MAX_HEADER_BYTES: usize = 8 * 1024; -const MAX_CLAIMS_BYTES: usize = 8 * 1024; -const MAX_CLIENT_ID_BYTES: usize = 256; -const MAX_JTI_BYTES: usize = 256; -/// Evidence rejects an actor longer than this, and a selector value longer than -/// this could not satisfy any selector profile. -const MAX_DELEGATION_VALUE_BYTES: usize = 512; - -/// Tolerance for clock difference between a caller and Mint. Applied to the -/// assertion's own `exp` and `nbf`, not to the tokens Mint issues. -const CLOCK_SKEW_SECONDS: i64 = 30; - -/// `JWT` is the conventional RFC 7523 assertion type. The explicit type is -/// accepted too, for callers that prefer unambiguous typing. -const ALLOWED_ASSERTION_TYP: [&str; 2] = ["JWT", "client-assertion+jwt"]; - -/// The parsed but *unverified* surface of an assertion, used only to decide -/// which client's keys to verify against. -struct AssertionPreflight { - claims: Value, -} - -/// A delegation request that the registry permits, ready to be minted. -/// -/// Every value here came from the signed assertion and was then checked against -/// the client's registration: the actor against its permitted set, and the -/// subject against the exact selector fields it declared. Nothing unbounded or -/// undeclared survives into this type. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ResolvedDelegation { - actor: String, - /// Selector field to its value, keyed exactly as the registration declared. - subject: BTreeMap, -} - -impl ResolvedDelegation { - /// Crate-internal because the type's meaning is that its contents already - /// passed [`build_delegation`]; nothing outside this crate may assert one. - pub(crate) fn new(actor: String, subject: BTreeMap) -> Self { - Self { actor, subject } - } - - #[must_use] - pub fn actor(&self) -> &str { - &self.actor - } - - #[must_use] - pub fn subject(&self) -> &BTreeMap { - &self.subject - } -} - -/// An authenticated client, and the delegation it authenticated for. -#[derive(Clone, Debug)] -pub struct AuthenticatedClient { - pub client: Arc, - pub delegation: Option, -} - -/// Authenticates registered client credentials against a registry snapshot. -/// -/// One verifier is built per `private_key_jwt` client at construction time, -/// each bound to that client's own static JWK set. Client-secret registrations -/// carry no key set and take the constant-time fingerprint path instead. -pub struct ClientAuthenticator { - registry: Arc, - verifiers: BTreeMap>, - maximum_lifetime_seconds: i64, - replay: Arc, -} - -impl std::fmt::Debug for ClientAuthenticator { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ClientAuthenticator") - .field("clients", &self.registry.len()) - .field("private_key_jwt_clients", &self.verifiers.len()) - .field("maximum_lifetime_seconds", &self.maximum_lifetime_seconds) - .finish_non_exhaustive() - } -} - -impl ClientAuthenticator { - /// Build one verifier per registered `private_key_jwt` client. - /// - /// The `replay` cache is passed in rather than created here so that - /// reloading the registry never forgets spent assertion identifiers. - #[must_use] - pub fn new( - registry: Arc, - config: &ClientAssertionConfig, - replay: Arc, - ) -> Self { - let algorithms = config - .algorithms - .iter() - .map(|algorithm| algorithm.as_jsonwebtoken()) - .collect::>(); - let allowed_typ = ALLOWED_ASSERTION_TYP.map(ToOwned::to_owned).to_vec(); - - let mut verifiers = BTreeMap::new(); - for client_id in registry.client_ids() { - let client = registry - .get(client_id) - .expect("client id came from this registry"); - if !client.accepts_private_key_jwt() { - continue; - } - // The static set holds this client's public keys and nothing else. - let fetcher = Arc::new(JwksFetcher::new_static( - client.jwks().clone(), - JwksFetcherConfig::defaults(), - )); - let verifier_config = TokenVerifierConfig::access_token_profile( - // An assertion issues from the client itself. - client_id.to_owned(), - vec![config.audience.clone()], - algorithms.clone(), - allowed_typ.clone(), - ) - .with_leeway(Duration::from_secs(CLOCK_SKEW_SECONDS.unsigned_abs())); - verifiers.insert( - client_id.to_owned(), - Arc::new(TokenVerifier::new(verifier_config, fetcher)), - ); - } - - Self { - registry, - verifiers, - maximum_lifetime_seconds: config.maximum_lifetime_seconds as i64, - replay, - } - } - - #[must_use] - pub fn registry(&self) -> &Arc { - &self.registry - } - - /// Authenticate a client assertion and return the client it proves. - /// - /// The returned client is the registry entry, which is where all authority - /// is read from. The one thing carried forward from the assertion payload - /// is the delegation request, and only after the registry has confirmed - /// that this client may delegate, to that actor, over exactly those subject - /// fields. - pub async fn authenticate( - &self, - assertion: &str, - now: i64, - ) -> Result { - let preflight = preflight(assertion)?; - let client_id = asserted_client_id(&preflight.claims)?; - - // Selecting the key set before verifying is the whole point: an - // unknown client never reaches a signature check, and a known one is - // checked against its own keys only. - let client = self - .registry - .get(client_id) - .ok_or_else(|| TokenError::invalid_client("unknown client"))?; - if !client.accepts_private_key_jwt() { - return Err(TokenError::invalid_client( - "client authentication method does not match its registration", - )); - } - let verifier = self - .verifiers - .get(client_id) - .ok_or_else(|| TokenError::server_error("registry and verifiers disagree"))?; - - let verified = verifier - .verify(assertion) - .await - .map_err(|_| TokenError::invalid_client("assertion signature or claims rejected"))?; - - // RFC 7523 section 3: for client authentication the subject is the - // client itself. Without this an assertion could name a different - // subject while still being signed by a legitimate client key. - let subject = verified - .claims - .sub - .as_deref() - .ok_or_else(|| TokenError::invalid_client("assertion has no subject"))?; - if subject != client_id { - return Err(TokenError::invalid_client( - "assertion subject does not match its issuer", - )); - } - - let issued_at = verified - .claims - .iat - .ok_or_else(|| TokenError::invalid_client("assertion has no issued-at"))?; - let expires_at = verified - .claims - .exp - .ok_or_else(|| TokenError::invalid_client("assertion has no expiry"))?; - // A long-lived assertion is a long-lived bearer credential. Bound it - // regardless of what the caller chose. - if expires_at <= issued_at - || expires_at.saturating_sub(issued_at) > self.maximum_lifetime_seconds - { - return Err(TokenError::invalid_client( - "assertion lifetime exceeds the configured maximum", - )); - } - // The verifier also checks expiry, but against its own read of the - // system clock. Freshness, the replay window, and the audit record must - // agree on one instant, so they are all decided against `now`. - if expires_at.saturating_add(CLOCK_SKEW_SECONDS) <= now { - return Err(TokenError::invalid_client("assertion has expired")); - } - if issued_at.saturating_sub(CLOCK_SKEW_SECONDS) > now { - return Err(TokenError::invalid_client("assertion is not yet issued")); - } - - // Resolved before the assertion is spent so a rejected delegation does - // not burn the caller's jti. - let delegation = resolve_delegation(client, &verified.claims.extra)?; - - let jti = verified - .claims - .extra - .get("jti") - .and_then(Value::as_str) - .ok_or_else(|| TokenError::invalid_client("assertion has no jti"))?; - if jti.is_empty() || jti.len() > MAX_JTI_BYTES { - return Err(TokenError::invalid_client("assertion jti is not bounded")); - } - // Namespaced by client so two clients choosing the same jti do not - // lock each other out. - let replay_key = format!("{client_id}\u{0}{jti}"); - // Remembered past `exp` by the same skew the freshness check tolerates. - // Forgetting it at `exp` would leave a window in which the assertion is - // still accepted but no longer recorded as spent. - self.replay - .remember( - &replay_key, - expires_at.saturating_add(CLOCK_SKEW_SECONDS), - now, - ) - .map_err(|error| match error { - ReplayError::AlreadyUsed => TokenError::invalid_client("assertion already used"), - ReplayError::Saturated => TokenError::server_error("replay cache saturated"), - ReplayError::Poisoned => TokenError::server_error("replay cache poisoned"), - })?; - - Ok(AuthenticatedClient { - client: Arc::clone(client), - delegation, - }) - } - - /// Authenticate a bounded client id and high-entropy secret. - /// - /// Secret-authenticated registrations are standard-authority only, so no - /// request-carried delegation can survive this authentication method. - pub fn authenticate_client_secret( - &self, - client_id: &str, - client_secret: &str, - ) -> Result { - let client = self - .registry - .get(client_id) - .ok_or_else(|| TokenError::invalid_client("unknown client"))?; - if client.accepts_private_key_jwt() { - return Err(TokenError::invalid_client( - "client authentication method does not match its registration", - )); - } - if !client.verifies_client_secret(client_secret) { - return Err(TokenError::invalid_client("client secret was rejected")); - } - Ok(AuthenticatedClient { - client: Arc::clone(client), - delegation: None, - }) - } -} - -/// Reconcile the delegation the assertion asks for with the one the registry -/// permits. -/// -/// Both directions fail closed. A client with no registered delegation cannot -/// obtain an actor or a bound subject by asking for one, and a client that *is* -/// registered for delegation cannot obtain an ordinary unbounded token by -/// omitting the request. The second half is what stops a delegated caller -/// quietly widening its own reach. -fn resolve_delegation( - client: &RegisteredClient, - claims: &serde_json::Map, -) -> Result, TokenError> { - let requested = claims.get(ON_BEHALF_OF_CLAIM); - match (client.delegation(), requested) { - (None, None) => Ok(None), - (None, Some(_)) => Err(TokenError::invalid_client( - "client is not registered to act on behalf of a subject", - )), - (Some(_), None) => Err(TokenError::invalid_client( - "a delegated client must name the actor and subject it acts for", - )), - (Some(registered), Some(requested)) => Ok(Some(build_delegation(registered, requested)?)), - } -} - -fn build_delegation( - registered: &Delegation, - requested: &Value, -) -> Result { - let invalid = |reason: &'static str| TokenError::invalid_client(reason); - - let requested = requested - .as_object() - .ok_or_else(|| invalid("the delegation request is malformed"))?; - // An unrecognized member would be silently dropped, leaving the caller - // believing it constrained something it did not. - if requested.len() != 2 || !requested.contains_key("actor") { - return Err(invalid("the delegation request is malformed")); - } - - let actor = requested - .get("actor") - .and_then(Value::as_str) - .ok_or_else(|| invalid("the delegation request is malformed"))?; - if actor.trim().is_empty() || actor.len() > MAX_DELEGATION_VALUE_BYTES { - return Err(invalid("the delegated actor is not bounded")); - } - if !registered.permits_actor(actor) { - return Err(invalid("the client may not act as this actor")); - } - - let subject = requested - .get("subject") - .and_then(Value::as_object) - .ok_or_else(|| invalid("the delegation request is malformed"))?; - // Exactly the declared fields: a missing one would leave the resource - // server unable to resolve the subject, and an extra one would be minted - // nowhere while looking to the caller as though it had been honoured. - if subject.len() != registered.subject_claims.len() { - return Err(invalid( - "the delegated subject does not match its registration", - )); - } - let mut resolved = BTreeMap::new(); - for field in registered.subject_claims.keys() { - let value = subject - .get(field) - .ok_or_else(|| invalid("the delegated subject does not match its registration"))?; - resolved.insert(field.clone(), bounded_selector_value(value)?); - } - - Ok(ResolvedDelegation::new(actor.to_owned(), resolved)) -} - -/// The value shapes a resource server can read back out as a selector value. -fn bounded_selector_value(value: &Value) -> Result { - match value { - Value::String(text) if !text.is_empty() && text.len() <= MAX_DELEGATION_VALUE_BYTES => { - Ok(value.clone()) - } - Value::Bool(_) => Ok(value.clone()), - // Only integers survive a JSON round trip into a selector value. - Value::Number(number) if number.is_i64() => Ok(value.clone()), - _ => Err(TokenError::invalid_client( - "a delegated subject value is not a bounded string, integer, or boolean", - )), - } -} - -/// Structural validation performed before any allocation-heavy or -/// cryptographic work, mirroring the strictness Evidence applies to bearer -/// tokens. -fn preflight(assertion: &str) -> Result { - let malformed = || TokenError::invalid_client("assertion is malformed"); - - if assertion.is_empty() || assertion.len() > MAX_ASSERTION_BYTES { - return Err(malformed()); - } - let segments = assertion.split('.').collect::>(); - if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { - return Err(malformed()); - } - - let header = decode_segment(segments[0], MAX_HEADER_BYTES)?; - if !header.is_object() { - return Err(malformed()); - } - let claims = decode_segment(segments[1], MAX_CLAIMS_BYTES)?; - if !claims.is_object() { - return Err(malformed()); - } - // A present but undecodable or empty signature is malformed regardless of - // what the verifier would later say about it. - let signature = URL_SAFE_NO_PAD - .decode(segments[2]) - .map_err(|_| malformed())?; - if signature.is_empty() { - return Err(malformed()); - } - - Ok(AssertionPreflight { claims }) -} - -/// Decode one base64url segment into strictly parsed JSON. -/// -/// `parse_json_strict` rejects duplicate members, so a header or claim set that -/// says one thing to a lenient parser and another to a strict one cannot get -/// past this point. -fn decode_segment(segment: &str, maximum_bytes: usize) -> Result { - let malformed = || TokenError::invalid_client("assertion is malformed"); - if segment.len() > maximum_bytes { - return Err(malformed()); - } - let bytes = URL_SAFE_NO_PAD.decode(segment).map_err(|_| malformed())?; - if bytes.len() > maximum_bytes { - return Err(malformed()); - } - parse_json_strict(&bytes).map_err(|_| malformed()) -} - -fn asserted_client_id(claims: &Value) -> Result<&str, TokenError> { - let issuer = claims - .get("iss") - .and_then(Value::as_str) - .ok_or_else(|| TokenError::invalid_client("assertion has no issuer"))?; - if issuer.is_empty() || issuer.len() > MAX_CLIENT_ID_BYTES { - return Err(TokenError::invalid_client( - "assertion issuer is not bounded", - )); - } - Ok(issuer) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use crate::config::Algorithm; - use registry_platform_authcommon::fingerprint_api_key; - use serde_json::json; - - // Deterministic per-seed Ed25519 keys so tests can hold several distinct - // client identities at once. - pub(crate) fn test_key(seed: u8) -> (registry_platform_crypto::PrivateJwk, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let d = URL_SAFE_NO_PAD.encode(seed_bytes); - let kid = format!("key-{seed}"); - let private = registry_platform_crypto::PrivateJwk::parse( - &json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}) - .to_string(), - ) - .expect("test private JWK parses"); - let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); - (private, public) - } - - fn sign_assertion( - private: ®istry_platform_crypto::PrivateJwk, - typ: &str, - claims: &Value, - ) -> String { - let kid = private.kid.clone().expect("test key has a kid"); - let header = json!({"alg": "EdDSA", "typ": typ, "kid": kid}); - let encode = |value: &Value| { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) - }; - let signing_input = format!("{}.{}", encode(&header), encode(claims)); - let signature = registry_platform_crypto::sign(signing_input.as_bytes(), private) - .expect("test key signs"); - format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) - } - - const AUDIENCE: &str = "https://mint.example.org/token"; - const NOW: i64 = 1_800_000_000; - - fn assertion_claims(client_id: &str, jti: &str) -> Value { - json!({ - "iss": client_id, - "sub": client_id, - "aud": AUDIENCE, - "iat": NOW, - "exp": NOW + 120, - "jti": jti, - }) - } - - fn registry_with(clients: &[(&str, &Value)]) -> Arc { - registry_of( - &clients - .iter() - .map(|(id, key)| (*id, *key, "")) - .collect::>(), - ) - } - - /// Each entry is a client id, its public key, and any extra registration - /// lines (a `delegation:` block, in these tests). - fn registry_of(clients: &[(&str, &Value, &str)]) -> Arc { - let directory = tempfile::tempdir().expect("temp dir"); - for (client_id, public, extra) in clients { - let document = format!( - "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [tag-{client_id}]\nkeys: [{public}]\n{extra}" - ); - std::fs::write(directory.path().join(format!("{client_id}.yaml")), document) - .expect("write client registration"); - } - Arc::new(ClientRegistry::load(directory.path()).expect("registry loads")) - } - - fn authenticator(registry: Arc) -> ClientAuthenticator { - let config = ClientAssertionConfig { - audience: AUDIENCE.to_owned(), - maximum_lifetime_seconds: 300, - algorithms: vec![Algorithm::EdDSA], - replay_cache_entries: 256, - }; - ClientAuthenticator::new(registry, &config, Arc::new(ReplayCache::new(256))) - } - - fn secret_registry(client_id: &str, secret: &str) -> Arc { - let directory = tempfile::tempdir().expect("temp dir"); - let fingerprint = fingerprint_api_key(secret); - let document = format!( - "clientId: {client_id}\nprincipal: urn:example:{client_id}\nauthorization: {{scopes: [registry:read]}}\nclientAuthentication:\n method: client-secret\n secretFingerprints: [{fingerprint}]\n" - ); - std::fs::write(directory.path().join("client.yaml"), document) - .expect("write client registration"); - Arc::new(ClientRegistry::load(directory.path()).expect("registry loads")) - } - - #[tokio::test] - async fn a_valid_assertion_authenticates_its_client() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - - let authenticated = authenticator - .authenticate(&assertion, NOW) - .await - .expect("valid assertion authenticates"); - assert_eq!(authenticated.client.client_id(), "client-a"); - assert_eq!(authenticated.client.principal(), "urn:example:client-a"); - assert_eq!(authenticated.delegation, None); - } - - #[test] - fn a_valid_client_secret_authenticates_only_its_registered_client() { - let authenticator = authenticator(secret_registry( - "qgis-installation", - "correct-high-entropy-client-secret-value", - )); - - let authenticated = authenticator - .authenticate_client_secret( - "qgis-installation", - "correct-high-entropy-client-secret-value", - ) - .expect("valid secret authenticates"); - assert_eq!(authenticated.client.client_id(), "qgis-installation"); - assert_eq!(authenticated.delegation, None); - - for (client_id, secret) in [ - ("qgis-installation", "wrong-client-secret"), - ( - "unknown-installation", - "correct-high-entropy-client-secret-value", - ), - ] { - assert_eq!( - authenticator - .authenticate_client_secret(client_id, secret) - .expect_err("an invalid credential is rejected") - .code(), - crate::error::TokenErrorCode::InvalidClient - ); - } - } - - #[tokio::test] - async fn a_client_cannot_switch_its_registered_authentication_method() { - let (private, public) = test_key(1); - let private_key_authenticator = authenticator(registry_with(&[("client-a", &public)])); - assert_eq!( - private_key_authenticator - .authenticate_client_secret("client-a", "any-secret") - .expect_err("a key client cannot authenticate with a secret"), - TokenError::invalid_client( - "client authentication method does not match its registration" - ) - ); - - let secret_authenticator = authenticator(secret_registry( - "client-a", - "correct-high-entropy-client-secret-value", - )); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - assert_eq!( - secret_authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a secret client cannot authenticate with an assertion"), - TokenError::invalid_client( - "client authentication method does not match its registration" - ) - ); - } - - /// The core security property. Client A holds a real, registered key. It - /// cannot use that key to speak as client B. - #[tokio::test] - async fn one_clients_key_cannot_sign_an_assertion_for_another_client() { - let (private_a, public_a) = test_key(1); - let (_private_b, public_b) = test_key(2); - let authenticator = authenticator(registry_with(&[ - ("client-a", &public_a), - ("client-b", &public_b), - ])); - - // A signs an assertion that claims to be B. - let forged = sign_assertion(&private_a, "JWT", &assertion_claims("client-b", "jti-1")); - - let error = authenticator - .authenticate(&forged, NOW) - .await - .expect_err("a forged assertion must be rejected"); - assert_eq!( - error, - TokenError::invalid_client("assertion signature or claims rejected") - ); - } - - /// Even naming its own kid does not help: the kid is looked up inside the - /// asserted client's key set, where A's key does not exist. - #[tokio::test] - async fn naming_a_foreign_kid_does_not_reach_another_clients_key_set() { - let (private_a, public_a) = test_key(1); - let (_private_b, public_b) = test_key(2); - let authenticator = authenticator(registry_with(&[ - ("client-a", &public_a), - ("client-b", &public_b), - ])); - - let mut claims = assertion_claims("client-b", "jti-1"); - claims["sub"] = json!("client-b"); - let forged = sign_assertion(&private_a, "JWT", &claims); - assert!(authenticator.authenticate(&forged, NOW).await.is_err()); - } - - #[tokio::test] - async fn an_unknown_client_is_rejected_before_any_signature_check() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-z", "jti-1")); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("unknown clients are rejected"); - assert_eq!(error, TokenError::invalid_client("unknown client")); - } - - #[tokio::test] - async fn the_subject_must_equal_the_issuer() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let mut claims = assertion_claims("client-a", "jti-1"); - claims["sub"] = json!("someone-else"); - let assertion = sign_assertion(&private, "JWT", &claims); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a mismatched subject is rejected"); - assert_eq!( - error, - TokenError::invalid_client("assertion subject does not match its issuer") - ); - } - - #[tokio::test] - async fn an_assertion_is_single_use() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - - assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a replayed assertion is rejected"); - assert_eq!(error, TokenError::invalid_client("assertion already used")); - } - - /// Freshness tolerates clock skew, so an assertion stays acceptable for a - /// short window past its own `exp`. The replay record has to outlive that - /// window: if it expired first, a captured assertion would become - /// replayable exactly as it was about to stop being useful. - #[tokio::test] - async fn an_assertion_stays_single_use_for_as_long_as_it_stays_acceptable() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - - assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); - - // One second past `exp`, still inside the accepted skew window. - let error = authenticator - .authenticate(&assertion, NOW + 121) - .await - .expect_err("a replayed assertion is rejected while it is still accepted"); - assert_eq!(error, TokenError::invalid_client("assertion already used")); - - // Past the window the assertion is refused on freshness instead, so the - // replay record has no further work to do. - let error = authenticator - .authenticate(&assertion, NOW + 151) - .await - .expect_err("an assertion past the skew window is refused"); - assert_eq!(error, TokenError::invalid_client("assertion has expired")); - } - - #[tokio::test] - async fn two_clients_may_use_the_same_jti_value() { - let (private_a, public_a) = test_key(1); - let (private_b, public_b) = test_key(2); - let authenticator = authenticator(registry_with(&[ - ("client-a", &public_a), - ("client-b", &public_b), - ])); - - let from_a = sign_assertion(&private_a, "JWT", &assertion_claims("client-a", "shared")); - let from_b = sign_assertion(&private_b, "JWT", &assertion_claims("client-b", "shared")); - assert!(authenticator.authenticate(&from_a, NOW).await.is_ok()); - assert!(authenticator.authenticate(&from_b, NOW).await.is_ok()); - } - - #[tokio::test] - async fn an_assertion_for_another_audience_is_rejected() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let mut claims = assertion_claims("client-a", "jti-1"); - claims["aud"] = json!("https://another-service.example.org/token"); - let assertion = sign_assertion(&private, "JWT", &claims); - - assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); - } - - #[tokio::test] - async fn expired_and_over_long_assertions_are_rejected() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - - let mut expired = assertion_claims("client-a", "jti-1"); - expired["iat"] = json!(NOW - 400); - expired["exp"] = json!(NOW - 300); - let assertion = sign_assertion(&private, "JWT", &expired); - assert_eq!( - authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an expired assertion is rejected"), - TokenError::invalid_client("assertion has expired") - ); - - let mut ahead = assertion_claims("client-a", "jti-3"); - ahead["iat"] = json!(NOW + 400); - ahead["exp"] = json!(NOW + 500); - let assertion = sign_assertion(&private, "JWT", &ahead); - assert_eq!( - authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an assertion issued in the future is rejected"), - TokenError::invalid_client("assertion is not yet issued") - ); - - let mut over_long = assertion_claims("client-a", "jti-2"); - over_long["exp"] = json!(NOW + 4_000); - let assertion = sign_assertion(&private, "JWT", &over_long); - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an over-long assertion is rejected"); - assert_eq!( - error, - TokenError::invalid_client("assertion lifetime exceeds the configured maximum") - ); - } - - #[tokio::test] - async fn an_assertion_without_a_jti_is_rejected() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let mut claims = assertion_claims("client-a", "jti-1"); - claims.as_object_mut().expect("claims object").remove("jti"); - let assertion = sign_assertion(&private, "JWT", &claims); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a jti is required"); - assert_eq!(error, TokenError::invalid_client("assertion has no jti")); - } - - #[tokio::test] - async fn an_unsigned_or_malformed_assertion_never_reaches_the_registry() { - let authenticator = authenticator(registry_with(&[("client-a", &test_key(1).1)])); - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT","kid":"key-1"}"#); - let claims = URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&assertion_claims("client-a", "jti-1")).expect("claims")); - - for candidate in [ - String::new(), - "not-a-jwt".to_owned(), - "a.b".to_owned(), - "a.b.c.d".to_owned(), - format!("{header}.{claims}."), - format!("{header}..x"), - format!("{header}.{claims}.!!!"), - ] { - let error = authenticator - .authenticate(&candidate, NOW) - .await - .expect_err("malformed assertions are rejected"); - assert_eq!(error, TokenError::invalid_client("assertion is malformed")); - } - } - - #[tokio::test] - async fn duplicate_json_members_are_rejected_by_the_strict_preflight() { - let (private, _public) = test_key(1); - let public = test_key(1).1; - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - - // Two `iss` members: a lenient parser would take one, a strict parser - // refuses to guess. - let raw_claims = format!( - r#"{{"iss":"client-a","iss":"client-b","sub":"client-a","aud":"{AUDIENCE}","iat":{NOW},"exp":{},"jti":"jti-1"}}"#, - NOW + 120 - ); - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT","kid":"key-1"}"#); - let claims = URL_SAFE_NO_PAD.encode(raw_claims.as_bytes()); - let signing_input = format!("{header}.{claims}"); - let signature = registry_platform_crypto::sign(signing_input.as_bytes(), &private) - .expect("test key signs"); - let assertion = format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("duplicate members are rejected"); - assert_eq!(error, TokenError::invalid_client("assertion is malformed")); - } - - const DELEGATION: &str = "delegation:\n actors: [urn:example:agent-one, urn:example:agent-two]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; - - fn on_behalf_of(jti: &str, request: Value) -> Value { - let mut claims = assertion_claims("client-a", jti); - claims[ON_BEHALF_OF_CLAIM] = request; - claims - } - - fn subject_request() -> Value { - json!({ - "actor": "urn:example:agent-one", - "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, - }) - } - - /// A delegated assertion carries the actor and the subject; both survive - /// only because the registration named them. - #[tokio::test] - async fn a_delegated_assertion_resolves_the_actor_and_subject_it_names() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); - - let authenticated = authenticator - .authenticate(&assertion, NOW) - .await - .expect("a permitted delegation authenticates"); - let delegation = authenticated.delegation.expect("delegation resolved"); - assert_eq!(delegation.actor(), "urn:example:agent-one"); - assert_eq!( - delegation.subject(), - &BTreeMap::from([ - ("birth_date".to_owned(), json!("1998-04-02")), - ("given_name".to_owned(), json!("Amara")), - ]) - ); - } - - /// Asking is not enough. Delegation is a property of the registration. - #[tokio::test] - async fn a_client_with_no_registered_delegation_cannot_ask_for_one() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an undelegated client is refused"); - assert_eq!( - error, - TokenError::invalid_client("client is not registered to act on behalf of a subject") - ); - } - - /// The other direction, and the one that is easy to miss: if omitting the - /// request produced an ordinary token, a delegated caller could widen its - /// own reach from one subject to every subject by leaving out a claim. - #[tokio::test] - async fn a_delegated_client_cannot_widen_itself_by_omitting_the_request() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - let assertion = sign_assertion(&private, "JWT", &assertion_claims("client-a", "jti-1")); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a delegated client must name its subject"); - assert_eq!( - error, - TokenError::invalid_client( - "a delegated client must name the actor and subject it acts for" - ) - ); - } - - #[tokio::test] - async fn an_actor_outside_the_registered_set_is_refused() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - let mut request = subject_request(); - request["actor"] = json!("urn:example:agent-three"); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); - - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an unregistered actor is refused"); - assert_eq!( - error, - TokenError::invalid_client("the client may not act as this actor") - ); - } - - /// Without an `actors` list the client names its own actor, so the actor is - /// an audit label rather than a bound. The subject binding is unaffected. - #[tokio::test] - async fn an_open_actor_list_still_binds_the_subject() { - let (private, public) = test_key(1); - let open = "delegation:\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; - let authenticator = authenticator(registry_of(&[("client-a", &public, open)])); - let mut request = subject_request(); - request["actor"] = json!("urn:example:anything"); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", request)); - - let authenticated = authenticator - .authenticate(&assertion, NOW) - .await - .expect("any actor is permitted"); - let delegation = authenticated.delegation.expect("delegation resolved"); - assert_eq!(delegation.actor(), "urn:example:anything"); - assert_eq!(delegation.subject().len(), 2); - } - - /// A missing field would leave the resource server unable to resolve the - /// subject; an extra one would be minted nowhere while looking to the caller - /// as though it had been honoured. - #[tokio::test] - async fn the_subject_must_carry_exactly_the_registered_fields() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - - let mut missing = subject_request(); - missing["subject"] = json!({"given_name": "Amara"}); - let mut extra = subject_request(); - extra["subject"] = json!({ - "given_name": "Amara", - "birth_date": "1998-04-02", - "national_id": "some-identifier", - }); - let mut renamed = subject_request(); - renamed["subject"] = json!({"given_name": "Amara", "family_name": "Okafor"}); - - for (index, request) in [missing, extra, renamed].into_iter().enumerate() { - let assertion = sign_assertion( - &private, - "JWT", - &on_behalf_of(&format!("jti-{index}"), request), - ); - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a mismatched subject is refused"); - assert_eq!( - error, - TokenError::invalid_client("the delegated subject does not match its registration") - ); - } - } - - /// Only the shapes a resource server can read back out as a selector value. - #[tokio::test] - async fn a_subject_value_that_is_not_a_selector_value_is_refused() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - - for (index, value) in [ - json!(null), - json!(""), - json!(1.5), - json!(["Amara"]), - json!({"value": "Amara"}), - json!("x".repeat(513)), - ] - .into_iter() - .enumerate() - { - let mut request = subject_request(); - request["subject"] = json!({"given_name": value, "birth_date": "1998-04-02"}); - let assertion = sign_assertion( - &private, - "JWT", - &on_behalf_of(&format!("jti-{index}"), request), - ); - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("an unusable subject value is refused"); - assert_eq!( - error, - TokenError::invalid_client( - "a delegated subject value is not a bounded string, integer, or boolean" - ) - ); - } - - // Integers and booleans are selector values, so they are accepted. - let mut numeric = subject_request(); - numeric["subject"] = json!({"given_name": 42, "birth_date": true}); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-ok", numeric)); - assert!(authenticator.authenticate(&assertion, NOW).await.is_ok()); - } - - /// A malformed request must not cost the caller its `jti`: the delegation is - /// reconciled before the assertion is spent, so correcting the request and - /// retrying works. - #[tokio::test] - async fn a_refused_delegation_does_not_spend_the_assertion() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - - let mut wrong = subject_request(); - wrong["actor"] = json!("urn:example:agent-three"); - let refused = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", wrong)); - assert!(authenticator.authenticate(&refused, NOW).await.is_err()); - - // Same jti, corrected request. - let corrected = sign_assertion(&private, "JWT", &on_behalf_of("jti-1", subject_request())); - assert!(authenticator.authenticate(&corrected, NOW).await.is_ok()); - } - - #[tokio::test] - async fn a_structurally_malformed_delegation_request_is_refused() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_of(&[("client-a", &public, DELEGATION)])); - - let mut unknown_member = subject_request(); - unknown_member["scope"] = json!("everything"); - let mut no_subject = subject_request(); - no_subject - .as_object_mut() - .expect("request object") - .remove("subject"); - - for (index, request) in [ - json!("urn:example:agent-one"), - json!([{"actor": "urn:example:agent-one"}]), - json!({"subject": {"given_name": "Amara", "birth_date": "1998-04-02"}}), - json!({"actor": "urn:example:agent-one", "subject": "Amara"}), - unknown_member, - no_subject, - ] - .into_iter() - .enumerate() - { - let assertion = sign_assertion( - &private, - "JWT", - &on_behalf_of(&format!("jti-{index}"), request), - ); - let error = authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a malformed delegation request is refused"); - assert_eq!( - error, - TokenError::invalid_client("the delegation request is malformed") - ); - } - - let mut blank_actor = subject_request(); - blank_actor["actor"] = json!(" "); - let assertion = sign_assertion(&private, "JWT", &on_behalf_of("jti-blank", blank_actor)); - assert_eq!( - authenticator - .authenticate(&assertion, NOW) - .await - .expect_err("a blank actor is refused"), - TokenError::invalid_client("the delegated actor is not bounded") - ); - } - - #[tokio::test] - async fn an_access_token_type_is_not_accepted_as_a_client_assertion() { - let (private, public) = test_key(1); - let authenticator = authenticator(registry_with(&[("client-a", &public)])); - let assertion = sign_assertion(&private, "at+jwt", &assertion_claims("client-a", "jti-1")); - - assert!(authenticator.authenticate(&assertion, NOW).await.is_err()); - } -} diff --git a/crates/registry-mint/src/audit.rs b/crates/registry-mint/src/audit.rs deleted file mode 100644 index 2c5a17cd46..0000000000 --- a/crates/registry-mint/src/audit.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Fail-closed Mint audit over one durable, segmented keyed JSONL chain. - -use registry_platform_audit::{ - verify_segmented_audit_chain, AuditEnvelope, AuditError, AuditKeyHasher, AuditProfile, - ChainState, DurableSegmentedJsonlSink, -}; -use registry_platform_canonical_json::canonicalize_json; -use registry_platform_config::{SecretError, SecretProvider, SecretResolver}; -use serde::Serialize; -use thiserror::Error; -use zeroize::Zeroizing; - -use crate::{ - assertion::AuthenticatedClient, - config::{AuditConfig, SecretProvidersConfig}, - token::MintedToken, -}; - -const AUDIT_SCHEMA: &str = "registry.mint.audit/v1"; - -#[derive(Debug, Error)] -pub enum MintAuditError { - #[error("the audit hash key could not be read")] - Secret(#[source] SecretError), - #[error("the audit chain could not be initialized or written")] - Audit(#[from] AuditError), - #[error("an audit-safe reference could not be constructed")] - Reference, - #[error( - "sealed segment {sequence} is archived or missing from the chain; this is not corruption" - )] - SegmentMissing { sequence: u64 }, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum AuditPhase { - TokenRelease, - Denial, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum AuditDecision { - Issued, - Rejected, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct MintAuditEvent { - schema: &'static str, - operation: String, - phase: AuditPhase, - decision: AuditDecision, - #[serde(skip_serializing_if = "Option::is_none")] - client_pseudonym: Option, - #[serde(skip_serializing_if = "Option::is_none")] - authority_pseudonym: Option, - #[serde(skip_serializing_if = "Option::is_none")] - actor_pseudonym: Option, - #[serde(skip_serializing_if = "Option::is_none")] - subject_pseudonym: Option, - #[serde(skip_serializing_if = "Option::is_none")] - token_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - signing_key_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - expires_at_unix: Option, - #[serde(skip_serializing_if = "Option::is_none")] - delegated: Option, - #[serde(skip_serializing_if = "Option::is_none")] - safe_error_category: Option, -} - -/// Minimal operator report returned by `mint verify-audit`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MintAuditSummary { - pub segments: usize, - pub records: usize, - pub last_hash: Option<[u8; 32]>, - pub first_sequence: Option, - pub last_sequence: Option, - pub active_verified: bool, -} - -/// Process-lifetime Mint audit boundary. -pub struct MintAuditLog { - sink: DurableSegmentedJsonlSink, - chain: ChainState, - key_hasher: AuditKeyHasher, - key_version: u32, - scope: String, -} - -impl std::fmt::Debug for MintAuditLog { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("MintAuditLog") - .field("path", &self.sink.path()) - .field("key_version", &self.key_version) - .finish_non_exhaustive() - } -} - -impl MintAuditLog { - pub async fn initialize( - config: &AuditConfig, - secrets: &SecretProvidersConfig, - issuer: &str, - ) -> Result { - let profile = audit_profile(config, secrets)?; - let sink = DurableSegmentedJsonlSink::open(config.path.clone(), config.maximum_file_bytes)?; - let chain = profile.bootstrap_or_start_empty(&sink).await?; - Ok(Self { - sink, - chain, - key_hasher: profile.key_hasher(), - key_version: config.hash_key_version, - scope: issuer.to_owned(), - }) - } - - /// Check the audit configuration without taking the serving writer lock. - /// - /// `mint check` reads a configuration while the deployment it describes is - /// usually still serving, and the chain admits one writer for the life of - /// that process. Opening the sink here would report a healthy deployment - /// back as a broken one. The hash key is what a misconfigured deployment - /// actually gets wrong, and reading it takes nothing the writer holds. - pub fn check( - config: &AuditConfig, - secrets: &SecretProvidersConfig, - ) -> Result<(), MintAuditError> { - audit_profile(config, secrets)?; - Ok(()) - } - - /// Verify the retained chain without taking the serving writer lock. - pub fn verify( - config: &AuditConfig, - secrets: &SecretProvidersConfig, - ) -> Result { - let profile = audit_profile(config, secrets)?; - let summary = verify_segmented_audit_chain(&config.path, &profile.chain_hasher()).map_err( - |error| match error { - AuditError::SegmentMissing { sequence } => { - MintAuditError::SegmentMissing { sequence } - } - error => MintAuditError::Audit(error), - }, - )?; - Ok(MintAuditSummary { - segments: summary.segments, - records: summary.records, - last_hash: summary.last_hash, - first_sequence: summary.first_sequence, - last_sequence: summary.last_sequence, - active_verified: summary.active_verified, - }) - } - - pub async fn append_issued( - &self, - operation: &str, - authenticated: &AuthenticatedClient, - token: &MintedToken, - ) -> Result { - let client_pseudonym = self.pseudonym("client", authenticated.client.client_id())?; - let authority = if let Some(authorization) = authenticated.client.authorization() { - serde_json::json!({ - "principal": authenticated.client.principal(), - "scopes": authorization.scopes, - "claims": authorization.claims, - }) - } else { - let grant = authenticated - .client - .grant() - .map(|grant| serde_json::json!({"id": grant.id, "authority": grant.authority})); - serde_json::json!({ - "principal": authenticated.client.principal(), - "evidenceAudience": authenticated.client.evidence_audience(), - "requesterTags": authenticated.client.requester_tags(), - "grant": grant, - }) - }; - let authority = canonicalize_json(&authority).map_err(|_| MintAuditError::Reference)?; - let authority = String::from_utf8(authority).map_err(|_| MintAuditError::Reference)?; - let authority_pseudonym = self.pseudonym("authority", &authority)?; - let (actor_pseudonym, subject_pseudonym) = match &authenticated.delegation { - Some(delegation) => { - let actor = self.pseudonym("actor", delegation.actor())?; - let subject = serde_json::to_value(delegation.subject()) - .map_err(|_| MintAuditError::Reference)?; - let subject = canonicalize_json(&subject).map_err(|_| MintAuditError::Reference)?; - let subject = String::from_utf8(subject).map_err(|_| MintAuditError::Reference)?; - (Some(actor), Some(self.pseudonym("subject", &subject)?)) - } - None => (None, None), - }; - self.append(MintAuditEvent { - schema: AUDIT_SCHEMA, - operation: operation.to_owned(), - phase: AuditPhase::TokenRelease, - decision: AuditDecision::Issued, - client_pseudonym: Some(client_pseudonym), - authority_pseudonym: Some(authority_pseudonym), - actor_pseudonym, - subject_pseudonym, - token_id: Some(token.token_id().to_owned()), - signing_key_id: Some(token.signing_key_id().to_owned()), - expires_at_unix: Some(token.expires_at_unix()), - delegated: Some(authenticated.delegation.is_some()), - safe_error_category: None, - }) - .await - } - - pub async fn append_rejected( - &self, - operation: &str, - safe_error_category: &str, - ) -> Result { - self.append(MintAuditEvent { - schema: AUDIT_SCHEMA, - operation: operation.to_owned(), - phase: AuditPhase::Denial, - decision: AuditDecision::Rejected, - client_pseudonym: None, - authority_pseudonym: None, - actor_pseudonym: None, - subject_pseudonym: None, - token_id: None, - signing_key_id: None, - expires_at_unix: None, - delegated: None, - safe_error_category: Some(safe_error_category.to_owned()), - }) - .await - } - - #[must_use] - pub async fn ready(&self) -> bool { - self.chain.try_last_hash().is_some() && self.sink.ready().await - } - - fn pseudonym(&self, class: &str, protected: &str) -> Result { - if protected.is_empty() { - return Err(MintAuditError::Reference); - } - let digest = self - .key_hasher - .audit_reference_hash(class, &self.scope, protected) - .map_err(|_| MintAuditError::Reference)?; - Ok(format!("hmac-sha256:v{}:{digest}", self.key_version)) - } - - async fn append(&self, event: MintAuditEvent) -> Result { - self.chain - .append(&self.sink, event) - .await - .map_err(MintAuditError::from) - } -} - -fn audit_profile( - config: &AuditConfig, - secrets: &SecretProvidersConfig, -) -> Result { - let resolver = SecretResolver::new([SecretProvider::File], secrets.file.root.clone()) - .map_err(MintAuditError::Secret)?; - let secret = resolver - .resolve(&config.hash_key_ref) - .map_err(MintAuditError::Secret)?; - AuditProfile::production_from_secret_bytes(Zeroizing::new(secret.expose_secret().to_vec())) - .map_err(MintAuditError::Audit) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{fs, os::unix::fs::PermissionsExt}; - - // A fixed, non-secret audit HMAC key. Held as a byte literal rather than - // written inline so a secret scanner does not read the write call as an - // assignment of a live credential. - const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; - - fn fixture() -> (tempfile::TempDir, AuditConfig, SecretProvidersConfig) { - let directory = tempfile::tempdir().expect("temp dir"); - let secret = directory.path().join("audit-key"); - fs::write(&secret, AUDIT_HASH_KEY).expect("write audit key"); - fs::set_permissions(&secret, fs::Permissions::from_mode(0o600)).expect("restrict key"); - let config = AuditConfig { - path: directory.path().join("audit/mint.jsonl"), - maximum_file_bytes: 1_048_576, - hash_key_ref: "secret:file/audit-key".to_owned(), - hash_key_version: 1, - }; - let secrets = SecretProvidersConfig { - file: crate::config::FileSecretProviderConfig { - root: directory.path().to_path_buf(), - }, - }; - (directory, config, secrets) - } - - #[tokio::test] - async fn a_keyed_chain_restarts_and_verifies() { - let (_directory, config, secrets) = fixture(); - { - let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit initializes"); - audit - .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") - .await - .expect("first decision is durable"); - } - { - let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit restarts"); - audit - .append_rejected("urn:ulid:01K00000000000000000000001", "invalid-request") - .await - .expect("second decision is durable"); - } - let summary = MintAuditLog::verify(&config, &secrets).expect("chain verifies"); - assert_eq!(summary.segments, 1); - assert_eq!(summary.records, 2); - assert!(summary.last_hash.is_some()); - assert!(summary.active_verified); - } - - #[tokio::test] - async fn a_second_writer_is_refused() { - let (_directory, config, secrets) = fixture(); - let first = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("first writer initializes"); - let second = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org").await; - assert!(second.is_err(), "a second writer must not fork the chain"); - drop(first); - } - - #[tokio::test] - async fn corruption_is_refused_at_restart_and_verification() { - let (_directory, config, secrets) = fixture(); - { - let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit initializes"); - audit - .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") - .await - .expect("decision is durable"); - } - let mut contents = fs::read_to_string(&config.path).expect("read chain"); - contents = contents.replace("invalid-client", "invalid-request"); - fs::write(&config.path, contents).expect("tamper with chain"); - assert!(MintAuditLog::verify(&config, &secrets).is_err()); - assert!( - MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .is_err() - ); - } - - #[tokio::test] - async fn a_replacement_master_cannot_append_to_an_existing_epoch() { - let (directory, config, secrets) = fixture(); - { - let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit initializes"); - audit - .append_rejected("urn:ulid:01K00000000000000000000000", "invalid-client") - .await - .expect("decision is durable"); - } - fs::write( - directory.path().join("audit-key"), - b"abcdef0123456789abcdef0123456789", - ) - .expect("replace audit key"); - - assert!(MintAuditLog::verify(&config, &secrets).is_err()); - assert!( - MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .is_err(), - "a new audit master must start a fresh path and epoch" - ); - } - - #[tokio::test] - async fn rotation_seals_history_without_breaking_restart_or_verification() { - let (_directory, mut config, secrets) = fixture(); - config.maximum_file_bytes = 550; - { - let audit = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit initializes"); - for index in 0..8 { - audit - .append_rejected( - &format!("urn:ulid:01K0000000000000000000000{index}"), - "invalid-client", - ) - .await - .expect("decision is durable"); - } - } - let first_segment = config.path.with_extension("jsonl.00000001"); - assert!(first_segment.exists(), "rotation seals the active segment"); - let summary = MintAuditLog::verify(&config, &secrets).expect("segmented chain verifies"); - assert_eq!(summary.records, 8); - assert!(summary.segments > 1); - assert_eq!(summary.first_sequence, Some(1)); - - let restarted = MintAuditLog::initialize(&config, &secrets, "https://mint.example.org") - .await - .expect("audit restarts from the segmented tail"); - restarted - .append_rejected("urn:ulid:01K00000000000000000000009", "invalid-request") - .await - .expect("post-restart decision is durable"); - } -} diff --git a/crates/registry-mint/src/caller.rs b/crates/registry-mint/src/caller.rs deleted file mode 100644 index 1f1268cfcb..0000000000 --- a/crates/registry-mint/src/caller.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! The caller's half of the protocol: building a client assertion. -//! -//! This is what a client does, not what Mint does. It holds no server state, -//! reads no server configuration, and touches no signing key of Mint's. It -//! signs with the *caller's* own private key, exactly as an adopter's client -//! library would, and produces an assertion that the token endpoint then -//! verifies on its own terms. -//! -//! That separation is deliberate and worth stating plainly, because the obvious -//! alternative is a subcommand that signs an access token directly with Mint's -//! signing key. That would be a way to obtain authority without authenticating, -//! inside the binary whose entire purpose is to make authority depend on -//! authentication. There is no such path here and there should never be one. -//! -//! Getting an assertion right by hand is fiddly (exact claims, a fresh `jti`, a -//! lifetime inside the configured bound) and getting it wrong yields an opaque -//! `invalid_client`. A first-party builder removes that guesswork and doubles as -//! executable documentation of the format. - -use std::collections::BTreeMap; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_crypto::{PrivateJwk, SigningAlgorithm}; -use serde_json::{json, Map, Value}; - -use crate::{config::Algorithm, ON_BEHALF_OF_CLAIM}; - -/// Refusals that happen before anything is signed. -#[derive(Debug, Eq, PartialEq, thiserror::Error)] -pub enum AssertionError { - #[error("{0}")] - Invalid(&'static str), - #[error("the assertion could not be signed: {0}")] - Signing(String), -} - -/// What a caller is asking for. -/// -/// `actor` and `subject` are the delegation request. Mint requires them -/// together or not at all, and refuses a registered delegated client that omits -/// them, so this refuses the halfway states here rather than spending a network -/// round trip to be told. -#[derive(Debug)] -pub struct AssertionRequest<'a> { - pub client_id: &'a str, - /// The token endpoint's configured `clientAssertion.audience`. - pub audience: &'a str, - pub lifetime_seconds: i64, - pub actor: Option<&'a str>, - pub subject: Option>, -} - -/// Name the caller's algorithm in Mint's own vocabulary. -/// -/// The crypto crate signs with more algorithms than `clientAssertion.algorithms` -/// can name, and Mint's token endpoint verifies against nothing else, so an -/// assertion signed with one of the others is unverifiable at every deployment. -/// Returning [`Algorithm`] rather than a header string keeps the two -/// vocabularies coupled, and the match stays exhaustive so widening either one -/// is a decision made here rather than a silent consequence. -fn assertion_algorithm(algorithm: SigningAlgorithm) -> Result { - match algorithm { - SigningAlgorithm::EdDsa => Ok(Algorithm::EdDSA), - SigningAlgorithm::Es256 => Ok(Algorithm::ES256), - SigningAlgorithm::Rs256 => Ok(Algorithm::RS256), - SigningAlgorithm::Es384 | SigningAlgorithm::Rs384 => Err(AssertionError::Invalid( - "the signing key must state EdDSA, ES256, or RS256", - )), - } -} - -/// Build and sign one client assertion. -/// -/// `now` is a Unix timestamp, passed in rather than read so that the claim -/// arithmetic is testable. -pub fn sign_client_assertion( - key: &PrivateJwk, - request: &AssertionRequest<'_>, - now: i64, -) -> Result { - if request.client_id.trim().is_empty() { - return Err(AssertionError::Invalid("a client id is required")); - } - if request.audience.trim().is_empty() { - return Err(AssertionError::Invalid("an assertion audience is required")); - } - // Mint bounds the assertion lifetime and applies 30 seconds of clock skew - // either way; anything outside this is a caller error, not a policy choice. - if !(1..=300).contains(&request.lifetime_seconds) { - return Err(AssertionError::Invalid( - "the assertion lifetime must be 1..=300 seconds", - )); - } - - let mut claims = Map::new(); - claims.insert("iss".to_owned(), json!(request.client_id)); - claims.insert("sub".to_owned(), json!(request.client_id)); - claims.insert("aud".to_owned(), json!(request.audience)); - claims.insert("iat".to_owned(), json!(now)); - claims.insert("exp".to_owned(), json!(now + request.lifetime_seconds)); - // Every assertion is single use. A caller-chosen `jti` would make a repeat - // an accident waiting to happen, so it is generated here and never reused. - claims.insert("jti".to_owned(), json!(ulid::Ulid::new().to_string())); - - match (request.actor, &request.subject) { - (None, None) => {} - (Some(actor), Some(subject)) => { - if actor.trim().is_empty() { - return Err(AssertionError::Invalid("the actor must not be empty")); - } - if subject.is_empty() { - return Err(AssertionError::Invalid( - "the subject must name at least one selector field", - )); - } - claims.insert( - ON_BEHALF_OF_CLAIM.to_owned(), - json!({"actor": actor, "subject": subject}), - ); - } - _ => { - return Err(AssertionError::Invalid( - "a delegation needs both an actor and a subject", - )) - } - } - - let algorithm = assertion_algorithm( - key.algorithm() - .map_err(|error| AssertionError::Signing(error.to_string()))?, - )?; - let header = json!({ - "alg": algorithm.as_header_value(), - "typ": "JWT", - "kid": key - .kid - .as_deref() - .ok_or(AssertionError::Invalid("the signing key needs a kid"))?, - }); - - let encode = |value: &Value| -> Result { - serde_json::to_vec(value) - .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) - .map_err(|error| AssertionError::Signing(error.to_string())) - }; - let signing_input = format!("{}.{}", encode(&header)?, encode(&Value::Object(claims))?); - let signature = registry_platform_crypto::sign(signing_input.as_bytes(), key) - .map_err(|error| AssertionError::Signing(error.to_string()))?; - Ok(format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature) - )) -} - -#[cfg(test)] -mod tests { - use super::*; - - const NOW: i64 = 1_800_000_000; - - fn key() -> PrivateJwk { - let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); - PrivateJwk::parse( - &json!({ - "kty": "OKP", - "crv": "Ed25519", - "kid": "caller-key-1", - "alg": "EdDSA", - "x": URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(signing.to_bytes()), - }) - .to_string(), - ) - .expect("the test key parses") - } - - /// A P-384 key the crypto crate signs with and Mint's token endpoint has no - /// way to accept. - const P384_JWK: &str = r#"{"kty":"EC","crv":"P-384","d":"Cp2oq8BnIF6oQ2KWV-1yiR7Mf0rFOuDZ5nvS9E_9HGEODI76izZiDEFQ5kfSwCAg","x":"TH-XDvwYtzdc43QDOiBjfdQZTCx1k9Rz5ELDu_2NS8JWcCv8HlfK0T9rYijDIcAY","y":"eLx0gh3VmCC2DeubmC0CdDgno7aEBYEkz5Legyg-2GoLlFohSIop3zKCGSjhg7Ta","alg":"ES384","kid":"caller-key-p384"}"#; - - fn request<'a>(client_id: &'a str, audience: &'a str) -> AssertionRequest<'a> { - AssertionRequest { - client_id, - audience, - lifetime_seconds: 120, - actor: None, - subject: None, - } - } - - fn claims_of(assertion: &str) -> Value { - let payload = assertion.split('.').nth(1).expect("a payload segment"); - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).expect("base64url")) - .expect("claims parse") - } - - #[test] - fn an_assertion_carries_exactly_what_the_token_endpoint_requires() { - let assertion = sign_client_assertion( - &key(), - &request("scheduler", "https://mint.example.org/token"), - NOW, - ) - .expect("the assertion signs"); - - let claims = claims_of(&assertion); - assert_eq!(claims["iss"], json!("scheduler")); - assert_eq!(claims["sub"], json!("scheduler")); - assert_eq!(claims["aud"], json!("https://mint.example.org/token")); - assert_eq!(claims["iat"], json!(NOW)); - assert_eq!(claims["exp"], json!(NOW + 120)); - assert!(claims.get(ON_BEHALF_OF_CLAIM).is_none()); - - let header: Value = serde_json::from_slice( - &URL_SAFE_NO_PAD - .decode(assertion.split('.').next().expect("a header segment")) - .expect("base64url"), - ) - .expect("header parses"); - assert_eq!(header["alg"], json!("EdDSA")); - assert_eq!(header["typ"], json!("JWT")); - assert_eq!(header["kid"], json!("caller-key-1")); - } - - /// Reusing a `jti` is refused by Mint as a replay, so the builder must never - /// produce the same one twice even when called with an identical request. - #[test] - fn every_assertion_gets_its_own_jti() { - let key = key(); - let request = request("scheduler", "https://mint.example.org/token"); - let first = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); - let second = claims_of(&sign_client_assertion(&key, &request, NOW).expect("signs")); - - assert_ne!(first["jti"], second["jti"]); - assert!(first["jti"].as_str().is_some_and(|jti| !jti.is_empty())); - } - - #[test] - fn a_delegation_request_rides_inside_the_signed_claims() { - let subject = BTreeMap::from([ - ("given_name".to_owned(), json!("Amara")), - ("birth_date".to_owned(), json!("1998-04-02")), - ]); - let assertion = sign_client_assertion( - &key(), - &AssertionRequest { - actor: Some("urn:example:agent:scheduler"), - subject: Some(subject), - ..request("scheduler", "https://mint.example.org/token") - }, - NOW, - ) - .expect("the assertion signs"); - - let claims = claims_of(&assertion); - assert_eq!( - claims[ON_BEHALF_OF_CLAIM], - json!({ - "actor": "urn:example:agent:scheduler", - "subject": {"given_name": "Amara", "birth_date": "1998-04-02"}, - }) - ); - } - - /// Mint requires the actor and subject together, and refuses a registered - /// delegated client that sends neither. Answering here saves a round trip - /// that could only ever return an opaque `invalid_client`. - #[test] - fn half_a_delegation_is_refused_before_anything_is_signed() { - let audience = "https://mint.example.org/token"; - let expected = || AssertionError::Invalid("a delegation needs both an actor and a subject"); - - assert_eq!( - sign_client_assertion( - &key(), - &AssertionRequest { - actor: Some("urn:example:agent:scheduler"), - ..request("scheduler", audience) - }, - NOW, - ), - Err(expected()) - ); - assert_eq!( - sign_client_assertion( - &key(), - &AssertionRequest { - subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), - ..request("scheduler", audience) - }, - NOW, - ), - Err(expected()) - ); - } - - /// The crypto crate signs with more algorithms than `clientAssertion` can - /// name, and an assertion carrying one of the others is unverifiable at - /// every Mint deployment. Refusing here is the whole point of the builder: - /// the alternative is a well-formed assertion and an opaque `invalid_client` - /// from a remote token endpoint. - #[test] - fn a_key_no_deployment_could_verify_is_refused_before_anything_is_signed() { - let key = PrivateJwk::parse(P384_JWK).expect("the P-384 test key parses"); - assert_eq!( - key.algorithm().expect("the P-384 key states its algorithm"), - SigningAlgorithm::Es384, - "the crypto crate must sign ES384 for this test to prove anything" - ); - - assert_eq!( - sign_client_assertion( - &key, - &request("scheduler", "https://mint.example.org/token"), - NOW, - ), - Err(AssertionError::Invalid( - "the signing key must state EdDSA, ES256, or RS256" - )) - ); - } - - /// Narrowing to what Mint accepts must not rename anything: the header the - /// caller writes has to be the name the crypto crate gives the key it - /// signed with, or the signature is verified under the wrong algorithm. - #[test] - fn narrowing_to_mints_vocabulary_keeps_each_algorithms_own_name() { - for algorithm in [ - SigningAlgorithm::EdDsa, - SigningAlgorithm::Es256, - SigningAlgorithm::Rs256, - ] { - assert_eq!( - assertion_algorithm(algorithm) - .expect("the builder accepts this algorithm") - .as_header_value(), - algorithm.jwa_name() - ); - } - } - - #[test] - fn empty_and_out_of_range_inputs_are_refused() { - let audience = "https://mint.example.org/token"; - assert_eq!( - sign_client_assertion(&key(), &request(" ", audience), NOW), - Err(AssertionError::Invalid("a client id is required")) - ); - assert_eq!( - sign_client_assertion(&key(), &request("scheduler", ""), NOW), - Err(AssertionError::Invalid("an assertion audience is required")) - ); - for lifetime in [0, -1, 301] { - assert_eq!( - sign_client_assertion( - &key(), - &AssertionRequest { - lifetime_seconds: lifetime, - ..request("scheduler", audience) - }, - NOW, - ), - Err(AssertionError::Invalid( - "the assertion lifetime must be 1..=300 seconds" - )), - "lifetime {lifetime} must be refused" - ); - } - assert_eq!( - sign_client_assertion( - &key(), - &AssertionRequest { - actor: Some(" "), - subject: Some(BTreeMap::from([("given_name".to_owned(), json!("Amara"))])), - ..request("scheduler", audience) - }, - NOW, - ), - Err(AssertionError::Invalid("the actor must not be empty")) - ); - assert_eq!( - sign_client_assertion( - &key(), - &AssertionRequest { - actor: Some("urn:example:agent:scheduler"), - subject: Some(BTreeMap::new()), - ..request("scheduler", audience) - }, - NOW, - ), - Err(AssertionError::Invalid( - "the subject must name at least one selector field" - )) - ); - } -} diff --git a/crates/registry-mint/src/cli.rs b/crates/registry-mint/src/cli.rs deleted file mode 100644 index a64ab00992..0000000000 --- a/crates/registry-mint/src/cli.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Registry Mint command-line contract. - -use std::path::PathBuf; - -use clap::{CommandFactory, Parser, Subcommand}; - -const DEFAULT_HEALTHCHECK_URL: &str = "http://127.0.0.1:8081/ready"; - -#[derive(Debug, Parser)] -#[command( - name = "mint", - about = "Registry Stack token issuer", - version = registry_platform_buildinfo::DISPLAY_VERSION -)] -pub struct Cli { - #[command(subcommand)] - pub command: Command, -} - -#[derive(Debug, Subcommand)] -pub enum Command { - /// Load the configuration, keys, audit chain, and client registry, then exit. - Check { - /// Mint deployment configuration file. - #[arg(long, env = "MINT_CONFIG")] - config: PathBuf, - /// Claim the audit writer and prove every serving dependency is ready. - /// Use before startup, not against a Mint process that is already serving. - #[arg(long)] - require_runtime_dependencies: bool, - /// Also prove the configured audit sink resolves inside this absolute - /// directory, which the deployment declares persistent. - /// - /// The declared root is a storage boundary, not a second audit setting. - /// Mint resolves `audit.path` against the configuration file exactly as - /// startup resolves it and refuses when the result is not at or below - /// the root, which is what stops a container from mounting durable - /// storage at the conventional prefix while writing the chain somewhere - /// ephemeral. - #[arg( - long, - value_name = "ABSOLUTE_DIRECTORY", - requires = "require_runtime_dependencies" - )] - require_audit_under: Option, - }, - /// Serve the token endpoint until terminated. - Serve { - /// Mint deployment configuration file. - #[arg(long, env = "MINT_CONFIG")] - config: PathBuf, - }, - /// Probe a numeric private readiness endpoint without ambient proxy use. - Healthcheck { - /// Exact loopback or private-address readiness URL. - #[arg( - long, - env = "MINT_HEALTHCHECK_URL", - default_value = DEFAULT_HEALTHCHECK_URL - )] - url: String, - }, - /// Verify the retained keyed Mint audit chain named by the configuration. - VerifyAudit { - /// Mint deployment configuration file. - #[arg(long, env = "MINT_CONFIG")] - config: PathBuf, - }, - /// Provision high-entropy credentials for compatible managed clients. - ClientSecret { - #[command(subcommand)] - command: ClientSecretCommand, - }, - /// Obtain an access token from a running token endpoint, as a client would. - /// - /// This authenticates. It signs a client assertion with the caller's own - /// key and posts it; the endpoint decides. Nothing here can produce a token - /// the same request over the wire would not have produced. - Token { - /// The token endpoint, for example `https://mint.example.org/token`. - #[arg(long)] - url: String, - /// The `clientId` this caller is registered under. - #[arg(long)] - client_id: String, - /// The caller's private JWK. Must be owner-only and not a symlink. - #[arg(long)] - key: PathBuf, - /// The endpoint's configured `clientAssertion.audience`. Defaults to - /// `--url`, which is the usual configuration. - #[arg(long)] - audience: Option, - /// Request a delegated token for this actor. Requires `--subject-file`. - #[arg(long, requires = "subject_file")] - actor: Option, - /// A JSON object of subject selector fields, for the actor to act for. - /// - /// A file rather than repeated flags on purpose: these are a real - /// person's identifying details, and command lines are visible to every - /// process on the host and land in shell history. - #[arg(long, requires = "actor")] - subject_file: Option, - /// Assertion lifetime in seconds. - #[arg(long, default_value_t = 120)] - lifetime_seconds: i64, - /// Trust this PEM certificate bundle in addition to the system roots, - /// for a development deployment behind a private CA. - #[arg(long)] - ca_certificate: Option, - /// Print the full endpoint response instead of the access token alone. - #[arg(long)] - verbose: bool, - }, -} - -#[derive(Debug, Subcommand)] -pub enum ClientSecretCommand { - /// Generate one credential file and print its non-secret fingerprint. - Generate { - /// New owner-only file that receives the printable client secret. - #[arg(long)] - out: PathBuf, - }, -} - -/// Return the complete command tree without running Registry Mint. -pub fn command() -> clap::Command { - let mut command = Cli::command(); - command.build(); - command -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::error::ErrorKind; - - const TOKEN_ARGS: [&str; 8] = [ - "mint", - "token", - "--url", - "https://mint.example.org/token", - "--client-id", - "caller", - "--key", - "private.jwk", - ]; - - #[test] - fn token_delegation_requires_the_actor_and_subject_file_together() { - assert!(Cli::try_parse_from(TOKEN_ARGS).is_ok()); - - for lone_option in [["--actor", "scheduler"], ["--subject-file", "subject.json"]] { - let error = Cli::try_parse_from( - TOKEN_ARGS - .into_iter() - .chain(lone_option) - .collect::>(), - ) - .expect_err("one delegation option must require the other"); - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - } - - assert!(Cli::try_parse_from( - TOKEN_ARGS - .into_iter() - .chain(["--actor", "scheduler", "--subject-file", "subject.json",]) - .collect::>(), - ) - .is_ok()); - } - - #[test] - fn client_secret_generation_requires_an_output_path() { - assert!(Cli::try_parse_from([ - "mint", - "client-secret", - "generate", - "--out", - "client-secret" - ]) - .is_ok()); - let error = Cli::try_parse_from(["mint", "client-secret", "generate"]) - .expect_err("the output path is required"); - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - } - - #[test] - fn runtime_dependency_check_is_an_explicit_operator_choice() { - let ordinary = Cli::try_parse_from(["mint", "check", "--config", "mint.yaml"]) - .expect("ordinary live-deployment check parses"); - assert!(matches!( - ordinary.command, - Command::Check { - require_runtime_dependencies: false, - .. - } - )); - - let preflight = Cli::try_parse_from([ - "mint", - "check", - "--config", - "mint.yaml", - "--require-runtime-dependencies", - ]) - .expect("full dependency preflight parses"); - assert!(matches!( - preflight.command, - Command::Check { - require_runtime_dependencies: true, - .. - } - )); - } - - #[test] - fn requiring_an_audit_root_also_requires_the_runtime_dependency_proof() { - let parsed = Cli::try_parse_from([ - "mint", - "check", - "--config", - "mint.yaml", - "--require-runtime-dependencies", - "--require-audit-under", - "/var/lib/registry-mint", - ]) - .expect("the audit root pairs with the dependency proof"); - let Command::Check { - require_audit_under, - .. - } = parsed.command - else { - panic!("check parsed as another command"); - }; - assert_eq!( - Some(PathBuf::from("/var/lib/registry-mint")), - require_audit_under - ); - - // Containment is a claim about the sink the dependency proof claims, so - // it cannot be requested on its own. - let error = Cli::try_parse_from([ - "mint", - "check", - "--config", - "mint.yaml", - "--require-audit-under", - "/var/lib/registry-mint", - ]) - .expect_err("containment alone proves nothing about writability"); - assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); - } - - #[test] - fn healthcheck_uses_the_process_local_readiness_endpoint() { - let command = command(); - let healthcheck = command - .find_subcommand("healthcheck") - .expect("healthcheck subcommand exists"); - let url = healthcheck - .get_arguments() - .find(|argument| argument.get_id() == "url") - .expect("healthcheck URL argument exists"); - assert_eq!( - url.get_default_values(), - [std::ffi::OsStr::new(DEFAULT_HEALTHCHECK_URL)] - ); - assert_eq!( - url.get_env(), - Some(std::ffi::OsStr::new("MINT_HEALTHCHECK_URL")) - ); - } - - #[test] - fn every_config_option_has_public_help() { - let command = command(); - for name in ["check", "serve", "verify-audit"] { - let subcommand = command.find_subcommand(name).expect("public subcommand"); - let config = subcommand - .get_arguments() - .find(|argument| argument.get_id() == "config") - .expect("config option"); - assert!( - config - .get_long_help() - .or_else(|| config.get_help()) - .is_some_and(|help| !help.to_string().trim().is_empty()), - "{name} --config lacks public help" - ); - } - } -} diff --git a/crates/registry-mint/src/client_secret.rs b/crates/registry-mint/src/client_secret.rs deleted file mode 100644 index 127a3fa3b5..0000000000 --- a/crates/registry-mint/src/client_secret.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! Provisioning for high-entropy client secrets. -//! -//! The raw credential is written once to an owner-only file. Only its -//! canonical SHA-256 fingerprint reaches standard output and the reloadable -//! client registry. A generated secret carries 256 bits of operating-system -//! randomness before printable base64url encoding. - -use std::{ - fs::OpenOptions, - io::{self, Write as _}, - os::unix::fs::OpenOptionsExt as _, - path::Path, -}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_authcommon::fingerprint_api_key; -use thiserror::Error; -use zeroize::Zeroizing; - -const CLIENT_SECRET_ENTROPY_BYTES: usize = 32; -const PRIVATE_FILE_MODE: u32 = 0o600; - -#[derive(Debug, Error)] -pub enum ClientSecretGenerationError { - #[error("the operating system could not generate client-secret entropy")] - Entropy, - #[error("the client-secret output already exists")] - Exists, - #[error("the client-secret output could not be created")] - Create, - #[error("the client-secret output could not be written")] - Write, -} - -/// Generate one printable client secret and write it to a new owner-only file. -/// -/// The return value is the non-secret fingerprint operators place in one -/// client registration. The function never replaces a file and never returns -/// or prints the raw credential. -pub fn generate(path: &Path) -> Result { - let mut entropy = Zeroizing::new([0_u8; CLIENT_SECRET_ENTROPY_BYTES]); - getrandom::fill(entropy.as_mut_slice()).map_err(|_| ClientSecretGenerationError::Entropy)?; - let secret = Zeroizing::new(URL_SAFE_NO_PAD.encode(entropy.as_slice())); - let fingerprint = fingerprint_api_key(&secret); - - let mut options = OpenOptions::new(); - options.write(true).create_new(true).mode(PRIVATE_FILE_MODE); - let mut file = options.open(path).map_err(|error| match error.kind() { - io::ErrorKind::AlreadyExists => ClientSecretGenerationError::Exists, - _ => ClientSecretGenerationError::Create, - })?; - file.write_all(secret.as_bytes()) - .and_then(|()| file.write_all(b"\n")) - .and_then(|()| file.sync_all()) - .map_err(|_| ClientSecretGenerationError::Write)?; - Ok(fingerprint) -} - -#[cfg(test)] -mod tests { - use std::{fs, os::unix::fs::PermissionsExt as _}; - - use registry_platform_authcommon::verify_api_key; - - use super::*; - - #[test] - fn generation_writes_one_owner_only_secret_and_returns_only_its_fingerprint() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("qgis-client-secret"); - - let fingerprint = generate(&path).expect("secret generates"); - let secret = fs::read_to_string(&path).expect("secret reads"); - let secret = secret.trim_end(); - - assert_eq!(secret.len(), 43); - assert!(secret - .bytes() - .all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') })); - assert_eq!(verify_api_key(secret, &fingerprint), Ok(true)); - assert!(!fingerprint.contains(secret)); - assert_eq!( - fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, - PRIVATE_FILE_MODE - ); - } - - #[test] - fn generation_never_replaces_an_existing_file() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("qgis-client-secret"); - fs::write(&path, "existing").expect("fixture writes"); - - assert!(matches!( - generate(&path), - Err(ClientSecretGenerationError::Exists) - )); - assert_eq!(fs::read_to_string(path).expect("fixture reads"), "existing"); - } - - #[test] - fn independently_generated_credentials_do_not_repeat() { - let directory = tempfile::tempdir().expect("temp dir"); - let first = generate(&directory.path().join("first")).expect("first generates"); - let second = generate(&directory.path().join("second")).expect("second generates"); - assert_ne!(first, second); - } -} diff --git a/crates/registry-mint/src/clients.rs b/crates/registry-mint/src/clients.rs deleted file mode 100644 index bf19e8fc1a..0000000000 --- a/crates/registry-mint/src/clients.rs +++ /dev/null @@ -1,1507 +0,0 @@ -//! The client registry: the server-side binding from keys to authority. -//! -//! This module is the reason Mint exists. A JWKS answers only "was this signed -//! by a trusted key?" The registry answers the question that actually matters: -//! "*this specific client* holds *these specific keys*, and is permitted to act -//! as *this principal* with *this server-governed authority*." -//! -//! Two rules keep that binding meaningful: -//! -//! 1. A client's assertion is verified against that client's keys only, never -//! against a pooled key set. See [`crate::assertion`]. -//! 2. Authority is read from here, never from the assertion payload. -//! -//! The registry is reloadable so that onboarding, offboarding, and caller key -//! rotation never require restarting a resource server. - -use std::{ - collections::{BTreeMap, BTreeSet}, - fmt, fs, - path::Path, - sync::Arc, -}; - -use jsonwebtoken::{jwk::JwkSet, DecodingKey}; -use registry_platform_authcommon::{parse_fingerprint, verify_api_key}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use thiserror::Error; -use url::Url; - -/// Evidence rejects principals longer than this, so a longer one could never -/// be used. -const MAX_PRINCIPAL_BYTES: usize = 512; -/// Evidence accepts at most this many requester tags. -const MAX_TAGS: usize = 32; -const MAX_KEYS_PER_CLIENT: usize = 8; -const MAX_CLIENT_FILE_BYTES: u64 = 256 * 1024; -const MAX_CLIENTS: usize = 4_096; -/// Evidence permits at most this many fields in one selector profile, so a -/// larger subject could never satisfy one. -const MAX_SUBJECT_FIELDS: usize = 16; -const MAX_DELEGATED_ACTORS: usize = 64; -/// The longest claim path Evidence will resolve. -const MAX_CLAIM_PATH_BYTES: usize = 512; -/// A registration stays substantially below the compact access-token ceiling. -const MAX_AUTHORIZATION_SCOPES: usize = 64; -const MAX_SCOPE_BYTES: usize = 256; -const MAX_AUTHORIZATION_CLAIMS: usize = 32; -const MAX_AUTHORIZATION_CLAIM_NAME_BYTES: usize = 128; -/// Relay accepts direct authority values only through this byte ceiling. -const MAX_AUTHORIZATION_CLAIM_VALUE_BYTES: usize = 512; -/// A resource server reads at most this many values from one multi-valued -/// authority claim, so a longer list could never be used in full. -const MAX_AUTHORIZATION_CLAIM_VALUES: usize = 64; -/// One active credential plus one planned rotation credential. -const MAX_CLIENT_SECRET_FINGERPRINTS: usize = 2; - -const RESERVED_ACCESS_TOKEN_CLAIMS: [&str; 9] = [ - "iss", - "aud", - "exp", - "iat", - "nbf", - "jti", - "client_id", - "sub", - "scope", -]; - -/// JWK members that only ever appear in private keys. `oth` carries the -/// remaining prime factors of a multi-prime RSA private key (RFC 7518 section -/// 6.3.2.7); a whole private key also carries `d`, but the guard must not depend -/// on which private member happens to be present. -const PRIVATE_JWK_MEMBERS: [&str; 8] = ["d", "p", "q", "dp", "dq", "qi", "k", "oth"]; - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum ClientRegistryError { - #[error("the client registry directory is unavailable")] - DirectoryUnavailable, - #[error("a client registration file is unreadable")] - Unreadable, - #[error("client registration {0} is invalid: {1}")] - Invalid(String, &'static str), - #[error("client registration document {0} is malformed: {1}")] - Document(String, String), - #[error("client id {0} is registered more than once")] - Duplicate(String), - #[error("the client registry holds more than {MAX_CLIENTS} clients")] - TooManyClients, -} - -/// A grant reference minted into tokens for callers acting under a recorded -/// authority. Evidence requires the id and authority together or not at all. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Grant { - pub id: String, - pub authority: String, -} - -/// Permission for a client to obtain tokens bound to one delegated actor acting -/// for one subject. -/// -/// This is what makes a delegated token narrower than an ordinary one rather -/// than merely differently labelled. The subject's selector values are minted -/// into the token at [`Delegation::subject_claims`], which must mirror the -/// `valueClaims` of the matching `authenticated-context` entitlement in the -/// resource server's bundle. The resource server then reads the subject from -/// the token and refuses any request that carries its own selector values, so a -/// token issued for one subject cannot reach another. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Delegation { - /// A closed set of actor identities this client may act as. Omitted means - /// the client names its own actor, bounded only by length. - #[serde(default)] - pub actors: Option>, - /// Selector field name to the claim path its value is minted at. - pub subject_claims: BTreeMap, -} - -/// One server-governed authority value written into a standard access token. -/// -/// A single string is the ordinary shape. A list is what a resource server's -/// multi-valued row boundary requires: it reads an array-valued claim and -/// refuses a scalar, so a registration that must satisfy one names every -/// permitted value here. -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] -#[serde(untagged)] -pub enum AuthorizationClaimValue { - Text(String), - List(Vec), -} - -impl AuthorizationClaimValue { - /// The JSON this value is minted as. - #[must_use] - pub fn to_json(&self) -> Value { - match self { - Self::Text(value) => Value::String(value.clone()), - Self::List(values) => Value::Array(values.iter().cloned().map(Value::String).collect()), - } - } -} - -/// Product-neutral authority written into a standard OAuth access token. -/// -/// The client assertion never supplies these values. They are fixed in the -/// reloadable server-side registration and emitted as one space-delimited -/// `scope` claim plus bounded direct string or string-list claims. -#[derive(Clone, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct Authorization { - pub scopes: Vec, - #[serde(default)] - pub claims: BTreeMap, -} - -/// The client authentication method selected by one registration. -/// -/// Existing registrations omit `clientAuthentication` and remain -/// `private_key_jwt` clients. Client-secret authentication is an explicit -/// compatibility profile and cannot silently become the default. -#[derive(Clone, Deserialize, Eq, PartialEq)] -#[serde( - tag = "method", - rename_all = "kebab-case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -enum ClientAuthenticationDocument { - PrivateKeyJwt, - ClientSecret { secret_fingerprints: Vec }, -} - -#[derive(Clone, Eq, PartialEq)] -pub enum ClientAuthentication { - PrivateKeyJwt, - ClientSecret { secret_fingerprints: Vec }, -} - -impl fmt::Debug for ClientAuthentication { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::PrivateKeyJwt => formatter.write_str("PrivateKeyJwt"), - Self::ClientSecret { - secret_fingerprints, - } => formatter - .debug_struct("ClientSecret") - .field( - "secret_fingerprints", - &format_args!("[{} redacted]", secret_fingerprints.len()), - ) - .finish(), - } - } -} - -impl fmt::Debug for Authorization { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Authorization") - .field("scopes", &format_args!("[{} redacted]", self.scopes.len())) - .field("claims", &format_args!("[{} redacted]", self.claims.len())) - .finish() - } -} - -impl Delegation { - /// Whether `actor` is one this client may act as. - #[must_use] - pub fn permits_actor(&self, actor: &str) -> bool { - match &self.actors { - Some(actors) => actors.iter().any(|permitted| permitted == actor), - None => true, - } - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ClientDocument { - client_id: String, - principal: String, - #[serde(default)] - evidence_audience: Option, - #[serde(default)] - requester_tags: Option>, - #[serde(default)] - grant: Option, - #[serde(default)] - delegation: Option, - #[serde(default)] - authorization: Option, - #[serde(default)] - client_authentication: Option, - #[serde(default)] - keys: Vec, -} - -/// One registered client: its public keys, and the authority Mint will assert -/// on its behalf. -#[derive(Clone)] -pub struct RegisteredClient { - client_id: String, - principal: String, - evidence_audience: Option, - requester_tags: Option>, - grant: Option, - delegation: Option, - authorization: Option, - authentication: ClientAuthentication, - jwks: JwkSet, -} - -impl RegisteredClient { - #[must_use] - pub fn client_id(&self) -> &str { - &self.client_id - } - - #[must_use] - pub fn principal(&self) -> &str { - &self.principal - } - - #[must_use] - pub fn evidence_audience(&self) -> Option<&str> { - self.evidence_audience.as_deref() - } - - #[must_use] - pub fn requester_tags(&self) -> Option<&[String]> { - self.requester_tags.as_deref() - } - - #[must_use] - pub fn grant(&self) -> Option<&Grant> { - self.grant.as_ref() - } - - /// The delegation this client is registered for, if any. A client with no - /// delegation may never obtain a token carrying an actor or a bound - /// subject. - #[must_use] - pub fn delegation(&self) -> Option<&Delegation> { - self.delegation.as_ref() - } - - /// The standard scoped authority this client is registered for, if any. - /// Evidence registrations instead expose the Evidence-specific accessors - /// above. Loading guarantees the two profiles never coexist. - #[must_use] - pub fn authorization(&self) -> Option<&Authorization> { - self.authorization.as_ref() - } - - /// The one client-authentication method this registration accepts. - #[must_use] - pub fn authentication(&self) -> &ClientAuthentication { - &self.authentication - } - - #[must_use] - pub fn accepts_private_key_jwt(&self) -> bool { - matches!(self.authentication, ClientAuthentication::PrivateKeyJwt) - } - - /// Verify a presented client secret against every active rotation - /// fingerprint without returning early on the matching slot. - #[must_use] - pub fn verifies_client_secret(&self, secret: &str) -> bool { - let ClientAuthentication::ClientSecret { - secret_fingerprints, - } = &self.authentication - else { - return false; - }; - let mut accepted = false; - for fingerprint in secret_fingerprints { - // Loading parsed every fingerprint already. Treat disagreement as - // non-acceptance rather than converting configuration trouble into - // a credential oracle. - accepted |= verify_api_key(secret, fingerprint).unwrap_or(false); - } - accepted - } - - /// The public keys registered for this client, and nothing else. This is - /// the set an assertion from this client is verified against. - #[must_use] - pub fn jwks(&self) -> &JwkSet { - &self.jwks - } -} - -/// Authority data identifies real callers, so it is kept out of logs for the -/// same reason Evidence redacts its authenticated context. -impl fmt::Debug for RegisteredClient { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("RegisteredClient") - .field("client_id", &"[redacted]") - .field("principal", &"[redacted]") - .field( - "evidence_audience", - &self.evidence_audience.as_ref().map(|_| "[redacted]"), - ) - .field( - "requester_tags", - &self - .requester_tags - .as_ref() - .map(|tags| format!("[{} redacted]", tags.len())), - ) - .field("grant", &self.grant.as_ref().map(|_| "[redacted]")) - .field( - "delegation", - &self.delegation.as_ref().map(|_| "[redacted]"), - ) - .field( - "authorization", - &self.authorization.as_ref().map(|_| "[redacted]"), - ) - .field("authentication", &self.authentication) - .field("keys", &self.jwks.keys.len()) - .finish() - } -} - -/// An immutable snapshot of the registered clients. -#[derive(Debug, Default)] -pub struct ClientRegistry { - clients: BTreeMap>, -} - -impl ClientRegistry { - /// Load every `*.yaml` registration in `directory`. - /// - /// The load is all-or-nothing: one malformed registration fails the whole - /// load, so a partially applied registry can never serve. - pub fn load(directory: &Path) -> Result { - let entries = - fs::read_dir(directory).map_err(|_| ClientRegistryError::DirectoryUnavailable)?; - let mut paths = Vec::new(); - for entry in entries { - let entry = entry.map_err(|_| ClientRegistryError::Unreadable)?; - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) == Some("yaml") { - paths.push(path); - } - } - paths.sort(); - - let mut clients = BTreeMap::new(); - for path in paths { - let client = load_client_file(&path)?; - if clients.contains_key(client.client_id()) { - return Err(ClientRegistryError::Duplicate( - client.client_id().to_owned(), - )); - } - clients.insert(client.client_id().to_owned(), Arc::new(client)); - } - if clients.len() > MAX_CLIENTS { - return Err(ClientRegistryError::TooManyClients); - } - Ok(Self { clients }) - } - - #[must_use] - pub fn get(&self, client_id: &str) -> Option<&Arc> { - self.clients.get(client_id) - } - - #[must_use] - pub fn len(&self) -> usize { - self.clients.len() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.clients.is_empty() - } - - #[must_use] - pub fn client_ids(&self) -> Vec<&str> { - self.clients.keys().map(String::as_str).collect() - } -} - -fn load_client_file(path: &Path) -> Result { - let name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("") - .to_owned(); - - let metadata = fs::symlink_metadata(path).map_err(|_| ClientRegistryError::Unreadable)?; - if !metadata.is_file() { - return Err(ClientRegistryError::Invalid( - name, - "registrations must be regular files", - )); - } - if metadata.len() > MAX_CLIENT_FILE_BYTES { - return Err(ClientRegistryError::Invalid( - name, - "registration is too large", - )); - } - - let text = fs::read_to_string(path).map_err(|_| ClientRegistryError::Unreadable)?; - let document: ClientDocument = serde_norway::from_str(&text) - .map_err(|error| ClientRegistryError::Document(name.clone(), error.to_string()))?; - build_client(&name, document) -} - -fn build_client( - name: &str, - document: ClientDocument, -) -> Result { - let invalid = |reason: &'static str| ClientRegistryError::Invalid(name.to_owned(), reason); - - if document.client_id.trim().is_empty() || document.client_id.len() > 256 { - return Err(invalid("client id must be 1..=256 bytes")); - } - if document.principal.trim().is_empty() || document.principal.len() > MAX_PRINCIPAL_BYTES { - return Err(invalid("principal must be 1..=512 bytes")); - } - validate_authority_profile(&document, &invalid)?; - let authentication = validate_client_authentication(&document, &invalid)?; - let jwks = match &authentication { - ClientAuthentication::PrivateKeyJwt => build_public_jwks(document.keys, &invalid)?, - ClientAuthentication::ClientSecret { .. } => JwkSet { keys: Vec::new() }, - }; - - Ok(RegisteredClient { - client_id: document.client_id, - principal: document.principal, - evidence_audience: document.evidence_audience, - requester_tags: document.requester_tags, - grant: document.grant, - delegation: document.delegation, - authorization: document.authorization, - authentication, - jwks, - }) -} - -fn validate_client_authentication( - document: &ClientDocument, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result { - match document.client_authentication.as_ref() { - None | Some(ClientAuthenticationDocument::PrivateKeyJwt) => { - if document.keys.is_empty() { - return Err(invalid( - "private-key-jwt authentication requires client keys", - )); - } - Ok(ClientAuthentication::PrivateKeyJwt) - } - Some(ClientAuthenticationDocument::ClientSecret { - secret_fingerprints, - }) => { - if !document.keys.is_empty() { - return Err(invalid( - "client-secret and private-key-jwt authentication are mutually exclusive", - )); - } - if document.authorization.is_none() - || document.evidence_audience.is_some() - || document.requester_tags.is_some() - || document.grant.is_some() - || document.delegation.is_some() - { - return Err(invalid( - "client-secret authentication requires standard authorization", - )); - } - if secret_fingerprints.is_empty() - || secret_fingerprints.len() > MAX_CLIENT_SECRET_FINGERPRINTS - { - return Err(invalid( - "between 1 and 2 client-secret fingerprints are required", - )); - } - for fingerprint in secret_fingerprints { - parse_fingerprint(fingerprint).map_err(|_| { - invalid("client-secret fingerprints must be canonical SHA-256 values") - })?; - } - if secret_fingerprints.iter().collect::>().len() - != secret_fingerprints.len() - { - return Err(invalid("client-secret fingerprints must be unique")); - } - Ok(ClientAuthentication::ClientSecret { - secret_fingerprints: secret_fingerprints.clone(), - }) - } - } -} - -fn validate_authority_profile( - document: &ClientDocument, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result<(), ClientRegistryError> { - let has_evidence = document.evidence_audience.is_some() - || document.requester_tags.is_some() - || document.grant.is_some() - || document.delegation.is_some(); - match (has_evidence, document.authorization.as_ref()) { - (true, Some(_)) => { - return Err(invalid( - "Evidence authority and standard authorization are mutually exclusive", - )) - } - (false, None) => { - return Err(invalid( - "an Evidence authority or standard authorization is required", - )) - } - (false, Some(authorization)) => return validate_authorization(authorization, invalid), - (true, None) => {} - } - - let evidence_audience = document - .evidence_audience - .as_deref() - .ok_or_else(|| invalid("Evidence authority requires evidenceAudience and requesterTags"))?; - let requester_tags = document - .requester_tags - .as_deref() - .ok_or_else(|| invalid("Evidence authority requires evidenceAudience and requesterTags"))?; - - if evidence_audience.len() > MAX_PRINCIPAL_BYTES { - return Err(invalid("evidence audience must be at most 512 bytes")); - } - // Evidence parses this claim as a URL and mixes it into the subject-binding - // MAC, so a value that fails to parse there must fail here. - Url::parse(evidence_audience).map_err(|_| invalid("evidence audience must be a URL"))?; - - if requester_tags.is_empty() || requester_tags.len() > MAX_TAGS { - return Err(invalid("between 1 and 32 requester tags are required")); - } - for tag in requester_tags { - if tag.trim().is_empty() || tag.len() > 256 { - return Err(invalid("requester tags must be 1..=256 bytes")); - } - } - let unique_tags = requester_tags.iter().collect::>(); - if unique_tags.len() != requester_tags.len() { - return Err(invalid("requester tags must be unique")); - } - - if let Some(grant) = &document.grant { - if grant.id.trim().is_empty() || grant.id.len() > MAX_PRINCIPAL_BYTES { - return Err(invalid("grant id must be 1..=512 bytes")); - } - if grant.authority.trim().is_empty() || grant.authority.len() > MAX_PRINCIPAL_BYTES { - return Err(invalid("grant authority must be 1..=512 bytes")); - } - } - - if let Some(delegation) = &document.delegation { - validate_delegation(delegation, invalid)?; - } - Ok(()) -} - -fn validate_authorization( - authorization: &Authorization, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result<(), ClientRegistryError> { - if authorization.scopes.is_empty() || authorization.scopes.len() > MAX_AUTHORIZATION_SCOPES { - return Err(invalid( - "between 1 and 64 authorization scopes are required", - )); - } - for scope in &authorization.scopes { - if scope.len() > MAX_SCOPE_BYTES || !valid_scope_token(scope) { - return Err(invalid( - "authorization scopes must be 1..=256 byte RFC 6749 scope-tokens", - )); - } - } - if authorization.scopes.iter().collect::>().len() != authorization.scopes.len() { - return Err(invalid("authorization scopes must be unique")); - } - - if authorization.claims.len() > MAX_AUTHORIZATION_CLAIMS { - return Err(invalid("at most 32 authorization claims are permitted")); - } - for (name, value) in &authorization.claims { - if !valid_authorization_claim_name(name) { - return Err(invalid("authorization claim names are invalid")); - } - if RESERVED_ACCESS_TOKEN_CLAIMS.contains(&name.as_str()) { - return Err(invalid( - "authorization claims must not shadow registered access-token claims", - )); - } - let values = match value { - AuthorizationClaimValue::Text(value) => std::slice::from_ref(value), - AuthorizationClaimValue::List(values) => { - // An empty list names a claim while asserting nothing, so it is - // refused rather than minted as an empty array. - if values.is_empty() || values.len() > MAX_AUTHORIZATION_CLAIM_VALUES { - return Err(invalid( - "authorization claim value lists must hold between 1 and 64 values", - )); - } - if values.iter().collect::>().len() != values.len() { - return Err(invalid("authorization claim values must be unique")); - } - values.as_slice() - } - }; - for value in values { - if value.is_empty() || value.len() > MAX_AUTHORIZATION_CLAIM_VALUE_BYTES { - return Err(invalid( - "authorization claim values must be 1..=512 byte direct strings", - )); - } - } - } - Ok(()) -} - -fn valid_scope_token(value: &str) -> bool { - !value.is_empty() - && value.bytes().all(|byte| { - byte == 0x21 || (0x23..=0x5b).contains(&byte) || (0x5d..=0x7e).contains(&byte) - }) -} - -fn valid_authorization_claim_name(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_AUTHORIZATION_CLAIM_NAME_BYTES - && valid_scope_token(value) -} - -fn validate_delegation( - delegation: &Delegation, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result<(), ClientRegistryError> { - if let Some(actors) = &delegation.actors { - // An empty list reads as "no restriction" but means "nothing may be - // requested", so it is refused rather than silently interpreted. - if actors.is_empty() || actors.len() > MAX_DELEGATED_ACTORS { - return Err(invalid("between 1 and 64 delegated actors are required")); - } - for actor in actors { - if actor.trim().is_empty() || actor.len() > MAX_PRINCIPAL_BYTES { - return Err(invalid("delegated actors must be 1..=512 bytes")); - } - } - if actors.iter().collect::>().len() != actors.len() { - return Err(invalid("delegated actors must be unique")); - } - } - - if delegation.subject_claims.is_empty() || delegation.subject_claims.len() > MAX_SUBJECT_FIELDS - { - return Err(invalid("between 1 and 16 subject claims are required")); - } - for (field, path) in &delegation.subject_claims { - if !valid_selector_field(field) { - return Err(invalid("subject claim field names are invalid")); - } - if !valid_claim_path(path) { - return Err(invalid("subject claim paths are invalid")); - } - } - - // Two fields minted at one path would leave whichever came last in place, - // so the resource server would read one field's value for both. - let paths = delegation.subject_claims.values().collect::>(); - if paths.len() != delegation.subject_claims.len() { - return Err(invalid("subject claim paths must be unique")); - } - - // Minting builds nested objects, so `identity` and `identity.given_name` - // cannot both hold a value. Sorting groups any prefix with what it - // prefixes, so comparing neighbours is enough. - let mut segmented = paths - .iter() - .map(|path| path.split('.').collect::>()) - .collect::>(); - segmented.sort_unstable(); - if segmented - .windows(2) - .any(|pair| pair[1].starts_with(&pair[0])) - { - return Err(invalid( - "subject claim paths must not nest inside one another", - )); - } - Ok(()) -} - -/// The selector field name grammar Evidence enforces on bundle selector -/// profiles. A field Mint accepts but Evidence rejects could never resolve. -fn valid_selector_field(value: &str) -> bool { - let bytes = value.as_bytes(); - !bytes.is_empty() - && bytes.len() <= 64 - && matches!(bytes.first(), Some(b'a'..=b'z')) - && bytes[1..].iter().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') - }) -} - -/// The claim path grammar Evidence resolves tokens against. -fn valid_claim_path(value: &str) -> bool { - if value.is_empty() || value.len() > MAX_CLAIM_PATH_BYTES { - return false; - } - value.split('.').all(|segment| { - let bytes = segment.as_bytes(); - !bytes.is_empty() - && matches!(bytes.first(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_')) - && bytes[1..] - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - }) -} - -fn build_public_jwks( - keys: Vec, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result { - if keys.is_empty() || keys.len() > MAX_KEYS_PER_CLIENT { - return Err(invalid("between 1 and 8 keys are required")); - } - - let mut kids = BTreeSet::new(); - for key in &keys { - let object = key - .as_object() - .ok_or_else(|| invalid("keys must be objects"))?; - reject_private_material(object, invalid)?; - let kid = object - .get("kid") - .and_then(Value::as_str) - .ok_or_else(|| invalid("every key must carry a kid"))?; - if kid.trim().is_empty() || kid.len() > 256 { - return Err(invalid("key ids must be 1..=256 bytes")); - } - if !kids.insert(kid.to_owned()) { - return Err(invalid("key ids must be unique within a client")); - } - } - - let jwks: JwkSet = serde_json::from_value(Value::Object( - [("keys".to_owned(), Value::Array(keys))] - .into_iter() - .collect::>(), - )) - .map_err(|_| invalid("keys are not a valid JWK set"))?; - - // Prove at load time that every key is actually usable, so a broken - // registration fails at startup rather than at the first token request. - for jwk in &jwks.keys { - DecodingKey::from_jwk(jwk).map_err(|_| invalid("a key is not a usable public key"))?; - } - Ok(jwks) -} - -/// Reject anything carrying private key material. A client registration is -/// public data; a private member here would mean an operator pasted a signing -/// key into the registry. -fn reject_private_material( - object: &Map, - invalid: &impl Fn(&'static str) -> ClientRegistryError, -) -> Result<(), ClientRegistryError> { - if contains_private_material(object) { - return Err(invalid("client keys must not contain private key material")); - } - Ok(()) -} - -/// Whether a JWK object carries any member that only exists in a private key. -/// -/// Shared with the published JWKS so neither the registry nor the public key -/// set can ever carry private material. -#[must_use] -pub fn contains_private_material(object: &Map) -> bool { - PRIVATE_JWK_MEMBERS - .iter() - .any(|member| object.contains_key(*member)) -} - -#[cfg(test)] -mod tests { - use super::*; - use registry_platform_authcommon::fingerprint_api_key; - - const CLIENT_SECRET: &str = "M7vEwCZZ5R2UjUVn5tQJ8w23F4w7T6s8d9P0yK1mN2o"; - - const CLIENT_A: &str = r#" -clientId: client-a -principal: urn:example:client-a -evidenceAudience: https://client-a.example.org -requesterTags: [ministry-of-health] -keys: - - {kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo} -"#; - - const SCOPED_CLIENT: &str = r#" -clientId: relay-consumer -principal: urn:example:relay-consumer -authorization: - scopes: [registry:business:read, registry:business:lookup] - claims: - purpose: statutory-consultation - authority: district-17 -keys: - - {kty: OKP, crv: Ed25519, kid: relay-consumer-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo} -"#; - - fn secret_client(fingerprints: &[String]) -> String { - let fingerprints = if fingerprints.is_empty() { - " secretFingerprints: []".to_owned() - } else { - format!( - " secretFingerprints:\n{}", - fingerprints - .iter() - .map(|fingerprint| format!(" - {fingerprint}")) - .collect::>() - .join("\n") - ) - }; - format!( - "clientId: qgis-installation\nprincipal: urn:example:qgis-installation\nauthorization:\n scopes: [registry:qgis:read]\nclientAuthentication:\n method: client-secret\n{fingerprints}\n" - ) - } - - fn registry_from(files: &[(&str, &str)]) -> Result { - let directory = tempfile::tempdir().expect("temp dir"); - for (name, contents) in files { - fs::write(directory.path().join(name), contents).expect("write client"); - } - ClientRegistry::load(directory.path()) - } - - fn load_one(contents: &str) -> Result { - registry_from(&[("client.yaml", contents)]) - } - - fn registry_error(files: &[(&str, &str)]) -> ClientRegistryError { - registry_from(files).expect_err("the registration must be rejected") - } - - fn load_error(contents: &str) -> ClientRegistryError { - registry_error(&[("client.yaml", contents)]) - } - - fn invalid(reason: &'static str) -> ClientRegistryError { - ClientRegistryError::Invalid("client.yaml".to_owned(), reason) - } - - #[test] - fn a_valid_registration_binds_keys_to_authority() { - let registry = load_one(CLIENT_A).expect("registry loads"); - let client = registry.get("client-a").expect("client-a is registered"); - - assert_eq!(client.principal(), "urn:example:client-a"); - assert_eq!( - client.evidence_audience(), - Some("https://client-a.example.org") - ); - assert_eq!( - client - .requester_tags() - .expect("the Evidence authority carries requester tags"), - ["ministry-of-health"] - ); - assert_eq!(client.grant(), None); - assert_eq!(client.authorization(), None); - assert_eq!(client.jwks().keys.len(), 1); - assert_eq!(registry.len(), 1); - } - - #[test] - fn a_scoped_registration_binds_standard_authority_without_evidence_fields() { - let registry = load_one(SCOPED_CLIENT).expect("registry loads"); - let client = registry - .get("relay-consumer") - .expect("the scoped client is registered"); - let authorization = client - .authorization() - .expect("the scoped authority is present"); - - assert_eq!(client.principal(), "urn:example:relay-consumer"); - assert_eq!(client.evidence_audience(), None); - assert_eq!(client.requester_tags(), None); - assert_eq!(client.grant(), None); - assert_eq!(client.delegation(), None); - assert_eq!( - authorization.scopes, - ["registry:business:read", "registry:business:lookup"] - ); - assert_eq!( - authorization.claims, - BTreeMap::from([ - ( - "authority".to_owned(), - AuthorizationClaimValue::Text("district-17".to_owned()) - ), - ( - "purpose".to_owned(), - AuthorizationClaimValue::Text("statutory-consultation".to_owned()) - ), - ]) - ); - } - - #[test] - fn a_standard_authority_claim_may_carry_a_list_of_values() { - let text = SCOPED_CLIENT.replace( - "authority: district-17", - "authority: [district-17, district-18]", - ); - let registry = load_one(&text).expect("registry loads"); - let authorization = registry - .get("relay-consumer") - .expect("the scoped client is registered") - .authorization() - .expect("the scoped authority is present"); - - assert_eq!( - authorization.claims, - BTreeMap::from([ - ( - "authority".to_owned(), - AuthorizationClaimValue::List(vec![ - "district-17".to_owned(), - "district-18".to_owned(), - ]) - ), - ( - "purpose".to_owned(), - AuthorizationClaimValue::Text("statutory-consultation".to_owned()) - ), - ]) - ); - } - - #[test] - fn a_listed_authority_claim_is_bounded_non_empty_and_free_of_repeats() { - let with_authority = |value: &str| { - SCOPED_CLIENT.replace("authority: district-17", &format!("authority: {value}")) - }; - - let too_many = (0..=MAX_AUTHORIZATION_CLAIM_VALUES) - .map(|index| format!("district-{index}")) - .collect::>() - .join(", "); - for values in ["[]", &format!("[{too_many}]")] { - assert_eq!( - load_error(&with_authority(values)), - invalid("authorization claim value lists must hold between 1 and 64 values"), - "value list {values} must be refused" - ); - } - - assert_eq!( - load_error(&with_authority("[district-17, district-17]")), - invalid("authorization claim values must be unique") - ); - - for value in [ - "''".to_owned(), - format!("'{}'", "a".repeat(MAX_AUTHORIZATION_CLAIM_VALUE_BYTES + 1)), - ] { - assert_eq!( - load_error(&with_authority(&format!("[{value}]"))), - invalid("authorization claim values must be 1..=512 byte direct strings") - ); - } - - for value in ["17", "true", "{district: 17}", "[17]", "[[district-17]]"] { - assert!( - load_one(&with_authority(value)).is_err(), - "claim value {value} is neither a string nor a string list and must be refused" - ); - } - } - - #[test] - fn a_client_secret_registration_is_explicit_bounded_and_verifiable() { - let fingerprint = fingerprint_api_key(CLIENT_SECRET); - let registry = load_one(&secret_client(std::slice::from_ref(&fingerprint))) - .expect("client-secret registration loads"); - let client = registry - .get("qgis-installation") - .expect("client is registered"); - - assert!(matches!( - client.authentication(), - ClientAuthentication::ClientSecret { secret_fingerprints } - if secret_fingerprints == std::slice::from_ref(&fingerprint) - )); - assert!(!client.accepts_private_key_jwt()); - assert!(client.verifies_client_secret(CLIENT_SECRET)); - assert!(!client.verifies_client_secret("wrong-client-secret")); - assert!(client.jwks().keys.is_empty()); - } - - #[test] - fn client_secret_authentication_is_opt_in_and_mutually_exclusive_with_keys() { - let registry = load_one(SCOPED_CLIENT).expect("legacy registration loads"); - assert!(registry - .get("relay-consumer") - .expect("client is registered") - .accepts_private_key_jwt()); - - let fingerprint = fingerprint_api_key(CLIENT_SECRET); - let with_key = secret_client(std::slice::from_ref(&fingerprint)).replace( - "clientAuthentication:", - "keys:\n - {kty: OKP, crv: Ed25519, kid: client-key, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo}\nclientAuthentication:", - ); - assert_eq!( - load_error(&with_key), - invalid("client-secret and private-key-jwt authentication are mutually exclusive") - ); - - let missing_keys = SCOPED_CLIENT - .lines() - .take_while(|line| *line != "keys:") - .collect::>() - .join("\n"); - assert_eq!( - load_error(&missing_keys), - invalid("private-key-jwt authentication requires client keys") - ); - } - - #[test] - fn client_secret_fingerprints_are_canonical_unique_and_rotation_bounded() { - let first = fingerprint_api_key(CLIENT_SECRET); - let second = fingerprint_api_key("another-high-entropy-client-secret-value"); - load_one(&secret_client(&[first.clone(), second.clone()])) - .expect("two rotation fingerprints load"); - - for (fingerprints, reason) in [ - ( - Vec::::new(), - "between 1 and 2 client-secret fingerprints are required", - ), - ( - vec![first.clone(), second.clone(), fingerprint_api_key("third")], - "between 1 and 2 client-secret fingerprints are required", - ), - ( - vec![first.clone(), first.clone()], - "client-secret fingerprints must be unique", - ), - ( - vec!["sha256:not-canonical".to_owned()], - "client-secret fingerprints must be canonical SHA-256 values", - ), - ] { - assert_eq!(load_error(&secret_client(&fingerprints)), invalid(reason)); - } - } - - #[test] - fn client_secret_authentication_cannot_enter_the_evidence_authority_profile() { - let fingerprint = fingerprint_api_key(CLIENT_SECRET); - let evidence = CLIENT_A - .lines() - .take_while(|line| *line != "keys:") - .chain([ - "clientAuthentication:", - " method: client-secret", - " secretFingerprints:", - &format!(" - {fingerprint}"), - ]) - .collect::>() - .join("\n"); - assert_eq!( - load_error(&evidence), - invalid("client-secret authentication requires standard authorization") - ); - } - - #[test] - fn authority_profiles_are_required_complete_and_mutually_exclusive() { - let no_authority = SCOPED_CLIENT.replace( - "authorization:\n scopes: [registry:business:read, registry:business:lookup]\n claims:\n purpose: statutory-consultation\n authority: district-17\n", - "", - ); - assert_eq!( - load_error(&no_authority), - invalid("an Evidence authority or standard authorization is required") - ); - - let incomplete = CLIENT_A.replace("evidenceAudience: https://client-a.example.org\n", ""); - assert_eq!( - load_error(&incomplete), - invalid("Evidence authority requires evidenceAudience and requesterTags") - ); - - let combined = CLIENT_A.replace( - "requesterTags: [ministry-of-health]\n", - "requesterTags: [ministry-of-health]\nauthorization: {scopes: [registry:read]}\n", - ); - assert_eq!( - load_error(&combined), - invalid("Evidence authority and standard authorization are mutually exclusive") - ); - } - - #[test] - fn standard_authority_is_closed_bounded_and_cannot_shadow_token_claims() { - let empty = SCOPED_CLIENT.replace( - "scopes: [registry:business:read, registry:business:lookup]", - "scopes: []", - ); - assert_eq!( - load_error(&empty), - invalid("between 1 and 64 authorization scopes are required") - ); - - for scopes in [ - "[registry:read, registry:read]", - "['registry:read records']", - "['registry:read\\records']", - ] { - let text = - SCOPED_CLIENT.replace("[registry:business:read, registry:business:lookup]", scopes); - assert!( - load_one(&text).is_err(), - "invalid scopes {scopes} must be refused" - ); - } - - for reserved in RESERVED_ACCESS_TOKEN_CLAIMS { - let text = SCOPED_CLIENT.replace("purpose:", &format!("{reserved}:")); - assert_eq!( - load_error(&text), - invalid("authorization claims must not shadow registered access-token claims"), - "claim {reserved} must be refused" - ); - } - - let empty_value = SCOPED_CLIENT.replace("purpose: statutory-consultation", "purpose: ''"); - assert_eq!( - load_error(&empty_value), - invalid("authorization claim values must be 1..=512 byte direct strings") - ); - } - - #[test] - fn standard_authority_size_limits_are_enforced_before_minting() { - let replace_authorization = |authorization: &str| { - SCOPED_CLIENT.replace( - "authorization:\n scopes: [registry:business:read, registry:business:lookup]\n claims:\n purpose: statutory-consultation\n authority: district-17", - authorization, - ) - }; - - let too_many_scopes = (0..=MAX_AUTHORIZATION_SCOPES) - .map(|index| format!("scope:{index}")) - .collect::>() - .join(", "); - assert_eq!( - load_error(&replace_authorization(&format!( - "authorization:\n scopes: [{too_many_scopes}]" - ))), - invalid("between 1 and 64 authorization scopes are required") - ); - - assert_eq!( - load_error(&replace_authorization(&format!( - "authorization:\n scopes: ['{}']", - "a".repeat(MAX_SCOPE_BYTES + 1) - ))), - invalid("authorization scopes must be 1..=256 byte RFC 6749 scope-tokens") - ); - - let too_many_claims = (0..=MAX_AUTHORIZATION_CLAIMS) - .map(|index| format!(" claim{index}: value")) - .collect::>() - .join("\n"); - assert_eq!( - load_error(&replace_authorization(&format!( - "authorization:\n scopes: [registry:read]\n claims:\n{too_many_claims}" - ))), - invalid("at most 32 authorization claims are permitted") - ); - - for name in [ - "claim with space".to_owned(), - "a".repeat(MAX_AUTHORIZATION_CLAIM_NAME_BYTES + 1), - ] { - assert_eq!( - load_error(&replace_authorization(&format!( - "authorization:\n scopes: [registry:read]\n claims:\n '{name}': value" - ))), - invalid("authorization claim names are invalid") - ); - } - - assert_eq!( - load_error(&replace_authorization(&format!( - "authorization:\n scopes: [registry:read]\n claims:\n purpose: '{}'", - "a".repeat(MAX_AUTHORIZATION_CLAIM_VALUE_BYTES + 1) - ))), - invalid("authorization claim values must be 1..=512 byte direct strings") - ); - } - - #[test] - fn private_key_material_is_rejected() { - for member in PRIVATE_JWK_MEMBERS { - let text = CLIENT_A.replace( - "alg: EdDSA,", - &format!("alg: EdDSA, {member}: nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A,"), - ); - assert_eq!( - load_error(&text), - invalid("client keys must not contain private key material"), - "member {member} must be rejected" - ); - } - } - - #[test] - fn keys_require_unique_non_empty_kids() { - let text = CLIENT_A.replace("kid: client-a-2026-01, ", ""); - assert_eq!(load_error(&text), invalid("every key must carry a kid")); - - let duplicated = format!( - "{CLIENT_A} - {{kty: OKP, crv: Ed25519, kid: client-a-2026-01, alg: EdDSA, x: 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo}}\n" - ); - assert_eq!( - load_error(&duplicated), - invalid("key ids must be unique within a client") - ); - } - - #[test] - fn evidence_audience_must_parse_as_a_url() { - let text = CLIENT_A.replace("https://client-a.example.org", "not-a-url"); - assert_eq!( - load_error(&text), - invalid("evidence audience must be a URL") - ); - } - - #[test] - fn requester_tags_are_required_bounded_and_unique() { - let text = CLIENT_A.replace("[ministry-of-health]", "[]"); - assert_eq!( - load_error(&text), - invalid("between 1 and 32 requester tags are required") - ); - - let text = CLIENT_A.replace("[ministry-of-health]", "[a, a]"); - assert_eq!(load_error(&text), invalid("requester tags must be unique")); - } - - #[test] - fn duplicate_client_ids_across_files_fail_the_whole_load() { - assert_eq!( - registry_error(&[("a.yaml", CLIENT_A), ("b.yaml", CLIENT_A)]), - ClientRegistryError::Duplicate("client-a".to_owned()) - ); - } - - #[test] - fn unknown_registration_fields_are_rejected() { - let text = CLIENT_A.replace("clientId: client-a", "clientId: client-a\nunexpected: true"); - assert!(matches!( - load_one(&text), - Err(ClientRegistryError::Document(_, _)) - )); - } - - #[test] - fn a_grant_requires_both_an_id_and_an_authority() { - let text = format!("{CLIENT_A}grant: {{id: grant-1, authority: statute-7}}\n"); - let registry = load_one(&text).expect("registry loads"); - let client = registry.get("client-a").expect("client-a is registered"); - assert_eq!( - client.grant(), - Some(&Grant { - id: "grant-1".to_owned(), - authority: "statute-7".to_owned() - }) - ); - - let text = format!("{CLIENT_A}grant: {{id: grant-1}}\n"); - assert!(matches!( - load_one(&text), - Err(ClientRegistryError::Document(_, _)) - )); - } - - /// A registration with a `delegation` block, whose subject claims mirror - /// the `valueClaims` of an `authenticated-context` entitlement. - fn delegated(body: &str) -> String { - format!("{CLIENT_A}delegation:\n{body}") - } - - const SUBJECT_CLAIMS: &str = - " subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; - - #[test] - fn a_delegated_registration_declares_its_actors_and_subject_claims() { - let text = delegated(&format!( - " actors: [urn:example:agent:scheduling]\n{SUBJECT_CLAIMS}" - )); - let registry = load_one(&text).expect("registry loads"); - let client = registry.get("client-a").expect("client-a is registered"); - let delegation = client.delegation().expect("client-a may delegate"); - - assert_eq!( - delegation.subject_claims, - BTreeMap::from([ - ("given_name".to_owned(), "identity.given_name".to_owned()), - ("birth_date".to_owned(), "identity.birth_date".to_owned()), - ]) - ); - assert!(delegation.permits_actor("urn:example:agent:scheduling")); - assert!(!delegation.permits_actor("urn:example:agent:other")); - - // Ordinary registrations stay undelegated, which is what makes an - // assertion asking to delegate refusable. - assert_eq!( - load_one(CLIENT_A) - .expect("registry loads") - .get("client-a") - .expect("client-a is registered") - .delegation(), - None - ); - } - - #[test] - fn an_omitted_actor_list_permits_any_actor_the_client_names() { - let registry = load_one(&delegated(SUBJECT_CLAIMS)).expect("registry loads"); - let client = registry.get("client-a").expect("client-a is registered"); - let delegation = client.delegation().expect("client-a may delegate"); - - assert_eq!(delegation.actors, None); - assert!(delegation.permits_actor("urn:example:agent:anything")); - } - - #[test] - fn an_actor_list_must_be_non_empty_bounded_and_unique() { - assert_eq!( - load_error(&delegated(&format!(" actors: []\n{SUBJECT_CLAIMS}"))), - invalid("between 1 and 64 delegated actors are required") - ); - assert_eq!( - load_error(&delegated(&format!(" actors: [a, a]\n{SUBJECT_CLAIMS}"))), - invalid("delegated actors must be unique") - ); - assert_eq!( - load_error(&delegated(&format!(" actors: ['']\n{SUBJECT_CLAIMS}"))), - invalid("delegated actors must be 1..=512 bytes") - ); - } - - #[test] - fn subject_claim_paths_must_use_the_resource_servers_claim_path_grammar() { - for path in ["identity..given_name", "1identity.given_name", "", "a.b!c"] { - let text = delegated(&format!(" subjectClaims:\n given_name: \"{path}\"\n")); - assert_eq!( - load_error(&text), - invalid("subject claim paths are invalid"), - "path {path:?} must be rejected" - ); - } - } - - #[test] - fn subject_claim_field_names_follow_the_selector_profile_grammar() { - for field in ["Given_Name", "1given", "given name", ""] { - let text = delegated(&format!( - " subjectClaims:\n \"{field}\": identity.given_name\n" - )); - assert_eq!( - load_error(&text), - invalid("subject claim field names are invalid"), - "field {field:?} must be rejected" - ); - } - } - - /// Minting builds nested objects, so a path and a path inside it cannot - /// both hold a value. - #[test] - fn subject_claim_paths_must_be_unique_and_must_not_nest() { - let text = delegated( - " subjectClaims:\n given_name: identity.name\n family_name: identity.name\n", - ); - assert_eq!( - load_error(&text), - invalid("subject claim paths must be unique") - ); - - let text = delegated( - " subjectClaims:\n given_name: identity\n family_name: identity.family_name\n", - ); - assert_eq!( - load_error(&text), - invalid("subject claim paths must not nest inside one another") - ); - } - - #[test] - fn a_delegation_must_bind_at_least_one_subject_claim() { - // Delegation with no subject binding would be an actor label on an - // otherwise unbounded token. - let text = delegated(" subjectClaims: {}\n"); - assert_eq!( - load_error(&text), - invalid("between 1 and 16 subject claims are required") - ); - - let fields = (0..17) - .map(|index| format!(" field_{index}: identity.f{index}\n")) - .collect::(); - assert_eq!( - load_error(&delegated(&format!(" subjectClaims:\n{fields}"))), - invalid("between 1 and 16 subject claims are required") - ); - } - - #[test] - fn debug_output_redacts_client_authority() { - let registry = load_one(CLIENT_A).expect("registry loads"); - let client = registry.get("client-a").expect("client-a is registered"); - let rendered = format!("{client:?}"); - assert!(!rendered.contains("urn:example:client-a")); - assert!(!rendered.contains("ministry-of-health")); - assert!(!rendered.contains("client-a.example.org")); - - let registry = load_one(SCOPED_CLIENT).expect("registry loads"); - let client = registry - .get("relay-consumer") - .expect("the scoped client is registered"); - let rendered = format!("{client:?}"); - assert!(!rendered.contains("registry:business:read")); - assert!(!rendered.contains("statutory-consultation")); - assert!(!rendered.contains("district-17")); - } - - #[test] - fn non_yaml_files_are_ignored() { - let registry = registry_from(&[ - ("client.yaml", CLIENT_A), - ("notes.txt", "not a registration"), - ("client.yaml.bak", "also not a registration"), - ]) - .expect("registry loads"); - assert_eq!(registry.len(), 1); - } -} diff --git a/crates/registry-mint/src/config.rs b/crates/registry-mint/src/config.rs deleted file mode 100644 index b9544a7470..0000000000 --- a/crates/registry-mint/src/config.rs +++ /dev/null @@ -1,1477 +0,0 @@ -//! Startup-only Mint configuration. -//! -//! Everything in this file is fixed for the lifetime of the serving process: -//! issuer identity, signing and audit keys, listener, and token policy. The one part of -//! Mint's state that is intentionally reloadable is the client registry, which -//! lives in [`crate::clients`]. - -use std::{ - collections::BTreeSet, - net::IpAddr, - path::{Component, Path, PathBuf}, -}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use serde::Deserialize; -use thiserror::Error; -use url::Url; - -/// Supported signature algorithms, shared by minted tokens and accepted client -/// assertions. -#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd)] -pub enum Algorithm { - EdDSA, - ES256, - RS256, -} - -impl Algorithm { - #[must_use] - pub fn as_jsonwebtoken(self) -> jsonwebtoken::Algorithm { - match self { - Self::EdDSA => jsonwebtoken::Algorithm::EdDSA, - Self::ES256 => jsonwebtoken::Algorithm::ES256, - Self::RS256 => jsonwebtoken::Algorithm::RS256, - } - } - - #[must_use] - pub fn as_header_value(self) -> &'static str { - match self { - Self::EdDSA => "EdDSA", - Self::ES256 => "ES256", - Self::RS256 => "RS256", - } - } -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum ConfigError { - #[error("the configuration file is unavailable")] - Unavailable, - #[error("the configuration document is invalid: {0}")] - Document(String), - #[error("configuration is invalid: {0}")] - Invalid(&'static str), -} - -fn default_jwks_path() -> String { - MINT_JWKS_PATH.to_owned() -} - -pub(crate) const MINT_JWKS_PATH: &str = "/.well-known/jwks.json"; -pub(crate) const MINT_TOKEN_PATH: &str = "/token"; -pub(crate) const MINT_METADATA_PATH: &str = "/.well-known/oauth-authorization-server"; -pub(crate) const MINT_OIDC_METADATA_PATH: &str = "/.well-known/openid-configuration"; -pub(crate) const MINT_HEALTH_PATH: &str = "/health"; -pub(crate) const MINT_READY_PATH: &str = "/ready"; - -/// Every path the router registers besides the configured JWKS path. -/// -/// The router panics when one path is registered twice, so the configured -/// JWKS path is checked against this list where the configuration is read. -pub(crate) const MINT_FIXED_ROUTES: [&str; 5] = [ - MINT_TOKEN_PATH, - MINT_METADATA_PATH, - MINT_OIDC_METADATA_PATH, - MINT_HEALTH_PATH, - MINT_READY_PATH, -]; - -fn default_maximum_request_bytes() -> u32 { - 16 * 1024 -} - -fn default_request_timeout_milliseconds() -> u64 { - 5_000 -} - -fn default_assertion_lifetime_seconds() -> u64 { - 300 -} - -fn default_replay_cache_entries() -> usize { - 8_192 -} - -fn default_principal_claim() -> String { - "sub".to_owned() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ListenerConfig { - pub address: String, - pub port: u16, - #[serde(default = "default_maximum_request_bytes")] - pub maximum_request_bytes: u32, - #[serde(default = "default_request_timeout_milliseconds")] - pub request_timeout_milliseconds: u64, -} - -impl ListenerConfig { - pub fn bind_address(&self) -> Result { - self.address - .parse() - .map_err(|_| ConfigError::Invalid("listener address is not an IP address")) - } - - /// Reject limits no token request can survive. - /// - /// A zero body limit or a zero timeout leaves Mint reporting itself ready - /// while every token request fails, which is an outage the readiness probe - /// cannot see. The bounds match the Evidence listener. - fn validate(&self) -> Result<(), ConfigError> { - if !(1_024..=1_048_576).contains(&self.maximum_request_bytes) { - return Err(ConfigError::Invalid( - "listener maximumRequestBytes must be 1024..=1048576", - )); - } - if !(1..=30_000).contains(&self.request_timeout_milliseconds) { - return Err(ConfigError::Invalid( - "listener requestTimeoutMilliseconds must be 1..=30000", - )); - } - Ok(()) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct SigningConfig { - pub algorithm: Algorithm, - /// Governed public JWK of the key that signs newly issued tokens. - pub active_public_jwk_file: PathBuf, - /// Public JWKs whose already-issued tokens may still be live. - #[serde(default)] - pub published_public_jwk_files: Vec, - /// Compromised key identifiers that must never be published or activated. - #[serde(default)] - pub revoked_key_ids: Vec, - #[serde(default = "default_jwks_path")] - pub jwks_path: String, -} - -/// Process-local access to the active signing key. -#[derive(Debug, Deserialize)] -#[serde( - tag = "kind", - rename_all = "kebab-case", - rename_all_fields = "camelCase", - deny_unknown_fields -)] -pub enum SignerConfig { - /// A mounted private JWK, admitted only in supervised local development. - LocalJwk { private_key_ref: String }, - /// Vault/OpenBao Transit reached only through a workload-local Unix socket. - Transit { - unix_socket_path: PathBuf, - mount: String, - key_name: String, - key_version: u32, - timeout_milliseconds: u64, - }, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SecretProvidersConfig { - pub file: FileSecretProviderConfig, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FileSecretProviderConfig { - /// Absolute directory beneath which logical `secret:file/...` names resolve. - pub root: PathBuf, -} - -/// Required, fail-closed audit storage for token decisions. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AuditConfig { - /// Append-only keyed JSONL chain, resolved relative to the configuration. - pub path: PathBuf, - /// Per-segment rotation threshold. Sealed segments are never deleted. - pub maximum_file_bytes: u64, - /// Owner-only master HMAC key resolved through the configured provider. - pub hash_key_ref: String, - /// Version label written into privacy-preserving audit handles. - pub hash_key_version: u32, -} - -/// Names of the Evidence claims Mint writes into Evidence-profile tokens. -/// -/// These must match the resource server's `authentication` block. Evidence, for -/// example, reads its principal, requester tags, evidence audience, and grant -/// pair from configurable claim names. -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ClaimNames { - #[serde(default = "default_principal_claim")] - pub principal: String, - pub requester_tags: String, - pub evidence_audience: String, - pub grant_id: String, - pub grant_authority: String, - /// The delegated actor identity, present only where a deployment issues - /// delegated tokens. Omitting it is what stops a registry entry that - /// declares delegation from ever being served. - #[serde(default)] - pub actor: Option, -} - -impl ClaimNames { - fn validate(&self) -> Result<(), ConfigError> { - let mut names = vec![ - self.principal.as_str(), - self.requester_tags.as_str(), - self.evidence_audience.as_str(), - self.grant_id.as_str(), - self.grant_authority.as_str(), - ]; - names.extend(self.actor.as_deref()); - for name in &names { - if name.trim().is_empty() || name.len() > 128 { - return Err(ConfigError::Invalid("claim names must be 1..=128 bytes")); - } - } - // A duplicated name would make one claim silently overwrite another, - // so authority could be smuggled through the wrong field. - let unique = names.iter().collect::>(); - if unique.len() != names.len() { - return Err(ConfigError::Invalid("claim names must be distinct")); - } - // These are written by Mint itself and must not be overridable. Minting - // writes the registered claims last, so any of these reused as a claim - // name would silently replace what Mint decided with what the registry - // did: an `aud` shadow yields a token whose audience is the principal - // and which still verifies. - for reserved in [ - "iss", - "aud", - "exp", - "iat", - "nbf", - "jti", - "client_id", - "scope", - ] { - if names.contains(&reserved) { - return Err(ConfigError::Invalid( - "authority claim names must not shadow registered JWT claims", - )); - } - } - // `sub` is the exception. It always carries the principal, so naming - // the principal claim `sub` rewrites the same value and is the default; - // any other claim named `sub` would replace the principal. - if names.iter().skip(1).any(|name| *name == "sub") { - return Err(ConfigError::Invalid( - "authority claim names must not shadow registered JWT claims", - )); - } - Ok(()) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct AccessTokenConfig { - /// The `aud` written into minted tokens. Matches the resource server's - /// configured audiences. - pub audiences: Vec, - pub lifetime_seconds: u64, - /// Required when at least one client uses the Evidence authority profile. - /// A deployment serving only standard scoped registrations does not need - /// Evidence claim names. - #[serde(default)] - pub claims: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ClientAssertionConfig { - /// The value clients must set as the assertion `aud`. Binding assertions to - /// this endpoint stops one presented to another service being replayed here. - pub audience: String, - #[serde(default = "default_assertion_lifetime_seconds")] - pub maximum_lifetime_seconds: u64, - pub algorithms: Vec, - #[serde(default = "default_replay_cache_entries")] - pub replay_cache_entries: usize, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ClientsConfig { - /// Directory of per-client registration files, relative to the config file. - pub directory: PathBuf, -} - -/// The transport validation boundary selected for this Mint process. -/// -/// The strict default preserves Mint's HTTPS-only deployment contract. -/// `SupervisedLocalDevelopment` is an explicit exception for a supervised -/// process pair on one canonical loopback origin. -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "kebab-case")] -pub enum ValidationMode { - #[default] - Strict, - SupervisedLocalDevelopment, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct MintConfig { - pub version: u32, - #[serde(default)] - pub validation_mode: ValidationMode, - pub issuer: String, - pub listener: ListenerConfig, - pub signing: SigningConfig, - pub signer: SignerConfig, - pub secret_providers: SecretProvidersConfig, - pub audit: AuditConfig, - pub access_tokens: AccessTokenConfig, - pub client_assertion: ClientAssertionConfig, - pub clients: ClientsConfig, -} - -impl MintConfig { - /// Load and validate a configuration document, resolving governed public - /// keys, audit storage, and the client registry relative to its directory. - pub fn load(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|_| ConfigError::Unavailable)?; - let mut config: Self = serde_norway::from_str(&text) - .map_err(|error| ConfigError::Document(error.to_string()))?; - let root = path - .parent() - .ok_or(ConfigError::Invalid("configuration path has no parent"))?; - // A relative configuration path names its directory relative to the - // working directory, which every later open tolerates but a proof - // about where a setting resolves cannot compare. The directory was - // just read through, so resolving it to its canonical location names - // the same files absolutely. - let root = if root.as_os_str().is_empty() { - Path::new(".") - } else { - root - }; - let root = root.canonicalize().map_err(|_| ConfigError::Unavailable)?; - config.resolve_paths(&root); - config.validate(&root)?; - Ok(config) - } - - fn resolve_paths(&mut self, root: &Path) { - let resolve = |path: &Path| -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - root.join(path) - } - }; - self.signing.active_public_jwk_file = resolve(&self.signing.active_public_jwk_file); - self.signing.published_public_jwk_files = self - .signing - .published_public_jwk_files - .iter() - .map(|path| resolve(path)) - .collect(); - self.audit.path = resolve(&self.audit.path); - self.clients.directory = resolve(&self.clients.directory); - } - - fn validate(&mut self, root: &Path) -> Result<(), ConfigError> { - if self.version != 1 { - return Err(ConfigError::Invalid( - "only configuration version 1 is supported", - )); - } - match self.validation_mode { - ValidationMode::Strict => validate_https_issuer(&self.issuer)?, - ValidationMode::SupervisedLocalDevelopment => { - if self.issuer.starts_with("https://") { - validate_https_issuer(&self.issuer)?; - validate_https_endpoint(&self.client_assertion.audience)?; - } else { - self.validate_supervised_local_development_transport()?; - } - } - } - self.listener.bind_address()?; - self.listener.validate()?; - - if self.signing.algorithm != Algorithm::ES256 { - return Err(ConfigError::Invalid( - "Mint service signing algorithm must be ES256", - )); - } - if self.signing.active_public_jwk_file.as_os_str().is_empty() { - return Err(ConfigError::Invalid("active public JWK file is required")); - } - if self.signing.published_public_jwk_files.len() > 32 { - return Err(ConfigError::Invalid( - "active and published public key set must contain at most 33 keys", - )); - } - if self.signing.revoked_key_ids.len() > 33 - || self - .signing - .revoked_key_ids - .iter() - .any(|kid| !is_thumbprint_key_id(kid)) - || self - .signing - .revoked_key_ids - .iter() - .collect::>() - .len() - != self.signing.revoked_key_ids.len() - { - return Err(ConfigError::Invalid( - "revoked key ids must be unique 43-character RFC 7638 thumbprints", - )); - } - match (&self.validation_mode, &self.signer) { - (ValidationMode::Strict, SignerConfig::Transit { .. }) - | (ValidationMode::SupervisedLocalDevelopment, SignerConfig::LocalJwk { .. }) - | (ValidationMode::SupervisedLocalDevelopment, SignerConfig::Transit { .. }) => {} - (ValidationMode::Strict, SignerConfig::LocalJwk { .. }) => { - return Err(ConfigError::Invalid( - "strict mode requires a Transit signer", - )); - } - } - match &self.signer { - SignerConfig::LocalJwk { private_key_ref } => { - validate_file_secret_ref(private_key_ref)?; - } - SignerConfig::Transit { - unix_socket_path, - mount, - key_name, - key_version, - timeout_milliseconds, - } => { - if !unix_socket_path.is_absolute() - || !valid_transit_name(mount) - || !valid_transit_name(key_name) - || *key_version == 0 - || !(1..=30_000).contains(timeout_milliseconds) - { - return Err(ConfigError::Invalid( - "Transit signer requires an absolute Unix socket, simple mount and key names, a non-zero key version, and a 1..=30000 millisecond timeout", - )); - } - } - } - if !self.secret_providers.file.root.is_absolute() { - return Err(ConfigError::Invalid( - "secret provider file root must be absolute", - )); - } - if !self.signing.jwks_path.starts_with('/') { - return Err(ConfigError::Invalid("jwks path must be absolute")); - } - if !is_plain_route_path(&self.signing.jwks_path) { - return Err(ConfigError::Invalid( - "jwks path must be a plain absolute path with no query, fragment, or route pattern", - )); - } - if MINT_FIXED_ROUTES.contains(&self.signing.jwks_path.as_str()) { - return Err(ConfigError::Invalid( - "jwks path must not take a route Mint already serves", - )); - } - if self.audit.path.as_os_str().is_empty() || self.audit.hash_key_version == 0 { - return Err(ConfigError::Invalid( - "audit path, hash key reference, and non-zero hash key version are required", - )); - } - validate_file_secret_ref(&self.audit.hash_key_ref)?; - if !(1_048_576..=1_099_511_627_776).contains(&self.audit.maximum_file_bytes) { - return Err(ConfigError::Invalid( - "audit maximumFileBytes must be 1048576..=1099511627776", - )); - } - let audit_secret_path = self - .secret_providers - .file - .root - .join(file_secret_name(&self.audit.hash_key_ref)); - if self.audit.path == audit_secret_path - || matches!( - &self.signer, - SignerConfig::LocalJwk { private_key_ref } - if private_key_ref == &self.audit.hash_key_ref - || self.audit.path - == self.secret_providers.file.root.join(file_secret_name(private_key_ref)) - ) - { - return Err(ConfigError::Invalid( - "audit storage, audit key, and local signing material must be distinct", - )); - } - // Record the location Mint will actually open. Until the destination is - // walked through the filesystem, a link anywhere in it can move the - // audit chain somewhere the recorded configuration never named, and the - // deployment that mounts the storage is the only place that would - // notice. Mint owns where its own sink resolves. - self.audit.path = resolve_audit_destination(&self.audit.path, root)?; - - if self.access_tokens.audiences.is_empty() || self.access_tokens.audiences.len() > 16 { - return Err(ConfigError::Invalid( - "between 1 and 16 audiences are required", - )); - } - for audience in &self.access_tokens.audiences { - if audience.trim().is_empty() || audience.len() > 512 { - return Err(ConfigError::Invalid("audiences must be 1..=512 bytes")); - } - } - // A long-lived bearer token is the thing Mint exists to avoid, and a - // token shorter than the verifier's clock skew is unusable. - if !(60..=3600).contains(&self.access_tokens.lifetime_seconds) { - return Err(ConfigError::Invalid( - "access token lifetime must be 60..=3600 seconds", - )); - } - if let Some(claims) = &self.access_tokens.claims { - claims.validate()?; - } - - if self.validation_mode == ValidationMode::Strict { - validate_https_endpoint(&self.client_assertion.audience)?; - } - if !(30..=600).contains(&self.client_assertion.maximum_lifetime_seconds) { - return Err(ConfigError::Invalid( - "client assertion lifetime must be 30..=600 seconds", - )); - } - if self.client_assertion.algorithms.is_empty() { - return Err(ConfigError::Invalid( - "at least one client assertion algorithm is required", - )); - } - if self.client_assertion.replay_cache_entries < 256 { - return Err(ConfigError::Invalid( - "the replay cache must hold at least 256 entries", - )); - } - Ok(()) - } - - fn validate_supervised_local_development_transport(&self) -> Result<(), ConfigError> { - let port = parse_canonical_supervised_local_origin(&self.issuer)?; - if self.listener.address != "127.0.0.1" || self.listener.port != port { - return Err(ConfigError::Invalid( - "supervised local development listener must exactly match its canonical issuer origin", - )); - } - if self.signing.jwks_path != MINT_JWKS_PATH { - return Err(ConfigError::Invalid( - "supervised local development JWKS path must match the fixed Mint path", - )); - } - if self.client_assertion.audience != format!("{}{MINT_TOKEN_PATH}", self.issuer) { - return Err(ConfigError::Invalid( - "supervised local development client assertion audience must match the fixed Mint token endpoint", - )); - } - Ok(()) - } -} - -/// Accept only a path that survives both trips the JWKS path has to make. -/// -/// The router registers this string literally and the metadata document -/// publishes it as `jwks_uri`. A query or fragment is lost on the way back: a -/// client fetching the advertised URI sends the path alone, so it would never -/// reach a route registered with the decoration attached. A route pattern is -/// the opposite failure, matching paths the metadata never advertised. Either -/// way Mint reports itself ready while its published key set does not resolve, -/// which is an outage no probe can see. -/// -/// So: one or more non-empty segments of unreserved path characters, no dot -/// segments, and nothing that could be read as a pattern. -fn is_plain_route_path(path: &str) -> bool { - let Some(rest) = path.strip_prefix('/') else { - return false; - }; - rest.split('/').all(|segment| { - !segment.is_empty() - && segment != "." - && segment != ".." - && segment.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') - }) - }) -} - -/// Resolves the already absolute audit destination to the location Mint opens. -/// -/// Every component that exists is resolved through its symbolic links, and the -/// components the first write creates are appended as written, so this is a -/// configuration proof rather than a defence against a concurrent writer. Any -/// resolution error fails closed. -/// -/// A destination that resolves under the configuration directory must stay -/// there: a link leading out of it would move the audit chain to storage the -/// configuration never named. An absolute destination elsewhere names storage -/// the operator chose, and only its links are resolved. Messages stay value -/// free so a failure never puts a configured location into shared logs. -fn resolve_audit_destination(destination: &Path, root: &Path) -> Result { - const DESCENDING: &str = "audit path must be absolute and free of parent traversal"; - const UNRESOLVED: &str = "audit path could not be resolved to a real location"; - - if !destination.is_absolute() { - return Err(ConfigError::Invalid(DESCENDING)); - } - let mut descending = PathBuf::new(); - for component in destination.components() { - match component { - // `Components` already drops an interior `.`; a `..` would let the - // tail climb back out of whatever the earlier components resolved - // to, so neither is resolved here. - Component::CurDir | Component::ParentDir => { - return Err(ConfigError::Invalid(DESCENDING)) - } - other => descending.push(other.as_os_str()), - } - } - - let existing = descending - .ancestors() - // `symlink_metadata` does not follow a final link, so a dangling link - // counts as existing and then fails canonicalization below. - .find(|candidate| candidate.symlink_metadata().is_ok()) - .ok_or(ConfigError::Invalid(UNRESOLVED))?; - let tail = descending - .strip_prefix(existing) - .map_err(|_| ConfigError::Invalid(UNRESOLVED))?; - let mut resolved = existing - .canonicalize() - .map_err(|_| ConfigError::Invalid(UNRESOLVED))?; - if !tail.as_os_str().is_empty() { - // Joining an empty tail would append a separator, and a destination - // that already exists resolves whole. Mint opens the location it - // records rather than only comparing it, and a trailing separator - // names a directory the sink is not. - resolved.push(tail); - } - if destination.starts_with(root) && !resolved.starts_with(root) { - return Err(ConfigError::Invalid( - "audit path must resolve inside the configuration directory", - )); - } - Ok(resolved) -} - -fn is_thumbprint_key_id(value: &str) -> bool { - if value.len() != 43 { - return false; - } - URL_SAFE_NO_PAD - .decode(value) - .is_ok_and(|bytes| bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == value) -} - -fn validate_file_secret_ref(reference: &str) -> Result<(), ConfigError> { - let Some(name) = reference.strip_prefix("secret:file/") else { - return Err(ConfigError::Invalid( - "secret references must use the exact secret:file/ grammar", - )); - }; - let bytes = name.as_bytes(); - if !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'-') - }) - { - return Err(ConfigError::Invalid( - "secret references must use the exact secret:file/ grammar", - )); - } - Ok(()) -} - -fn file_secret_name(reference: &str) -> &str { - reference - .strip_prefix("secret:file/") - .expect("validated file secret reference") -} - -fn valid_transit_name(value: &str) -> bool { - let bytes = value.as_bytes(); - !bytes.is_empty() - && bytes.len() <= 128 - && bytes - .iter() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) -} - -/// Parse the only HTTP origin admitted for supervised local development. -/// -/// Exact reconstruction rejects URL-parser aliases such as a trailing slash, -/// leading-zero port, alternate IPv4 spelling, credentials, query, or fragment. -fn parse_canonical_supervised_local_origin(value: &str) -> Result { - let port = value - .strip_prefix("http://127.0.0.1:") - .filter(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())) - .filter(|port| !port.starts_with('0')) - .and_then(|port| port.parse::().ok()) - .filter(|port| *port != 0) - .ok_or(ConfigError::Invalid( - "supervised local development issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", - ))?; - if value != format!("http://127.0.0.1:{port}") { - return Err(ConfigError::Invalid( - "supervised local development issuer must be a canonical 127.0.0.1 HTTP origin with an explicit non-zero port", - )); - } - Ok(port) -} - -/// Require an issuer that is `https`, has a host, and carries no credentials, -/// query, or fragment. Resource servers compare this string exactly, so any -/// variable part of it would weaken the comparison. -pub fn validate_https_issuer(issuer: &str) -> Result<(), ConfigError> { - let url = - Url::parse(issuer).map_err(|_| ConfigError::Invalid("issuer must be an absolute URL"))?; - if url.scheme() != "https" { - return Err(ConfigError::Invalid("issuer must use https")); - } - if !url.has_host() { - return Err(ConfigError::Invalid("issuer must have a host")); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(ConfigError::Invalid("issuer must not carry credentials")); - } - if url.query().is_some() || url.fragment().is_some() { - return Err(ConfigError::Invalid( - "issuer must not carry a query or fragment", - )); - } - Ok(()) -} - -fn validate_https_endpoint(endpoint: &str) -> Result<(), ConfigError> { - let url = Url::parse(endpoint) - .map_err(|_| ConfigError::Invalid("client assertion audience must be an absolute URL"))?; - if url.scheme() != "https" { - return Err(ConfigError::Invalid( - "client assertion audience must use https", - )); - } - if !url.has_host() { - return Err(ConfigError::Invalid( - "client assertion audience must have a host", - )); - } - if !url.username().is_empty() || url.password().is_some() { - return Err(ConfigError::Invalid( - "client assertion audience must not carry credentials", - )); - } - if url.query().is_some() || url.fragment().is_some() { - return Err(ConfigError::Invalid( - "client assertion audience must not carry a query or fragment", - )); - } - Ok(()) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use std::fs; - - pub(crate) const VALID: &str = r#" -version: 1 -issuer: https://mint.example.org -listener: {address: 127.0.0.1, port: 8081} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/mint.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: 7 - timeoutMilliseconds: 2000 -secretProviders: - file: {root: /run/registry-mint/secrets} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -clientAssertion: - audience: https://mint.example.org/token - algorithms: [EdDSA] -clients: - directory: clients -"#; - - fn load_from(text: &str) -> Result { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("mint.yaml"); - fs::write(&path, text).expect("write config"); - MintConfig::load(&path) - } - - fn load_error(text: &str) -> ConfigError { - load_from(text).expect_err("the document must be rejected") - } - - /// A valid configuration for tests in other modules that need one but do - /// not exercise loading itself. - pub(crate) fn sample_config() -> MintConfig { - load_from(VALID).expect("the sample configuration is valid") - } - - #[test] - fn a_valid_document_loads_and_resolves_paths_against_the_config_directory() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("mint.yaml"); - fs::write(&path, VALID).expect("write config"); - let config = MintConfig::load(&path).expect("valid config loads"); - // Settings resolve against the directory's canonical location, so the - // same files are named wherever the process was started. - let root = directory - .path() - .canonicalize() - .expect("canonical config directory"); - - assert_eq!(config.issuer, "https://mint.example.org"); - assert_eq!(config.validation_mode, ValidationMode::Strict); - assert_eq!( - config.signing.active_public_jwk_file, - root.join("public-keys/mint.jwk.json") - ); - assert_eq!(config.clients.directory, root.join("clients")); - assert_eq!(config.audit.path, root.join("audit/mint.jsonl")); - assert_eq!(config.audit.maximum_file_bytes, 1_073_741_824); - assert_eq!(config.audit.hash_key_ref, "secret:file/audit-hmac-key"); - assert_eq!(config.audit.hash_key_version, 1); - assert_eq!(config.signing.jwks_path, "/.well-known/jwks.json"); - assert_eq!(config.client_assertion.maximum_lifetime_seconds, 300); - } - - #[test] - fn audit_configuration_is_required_bounded_and_separate_from_secrets() { - assert!(load_from(&VALID.replace( - "audit:\n path: audit/mint.jsonl\n maximumFileBytes: 1073741824\n hashKeyRef: secret:file/audit-hmac-key\n hashKeyVersion: 1\n", - "" - )) - .is_err()); - assert_eq!( - load_error(&VALID.replace("hashKeyVersion: 1", "hashKeyVersion: 0")), - ConfigError::Invalid( - "audit path, hash key reference, and non-zero hash key version are required" - ) - ); - assert_eq!( - load_error(&VALID.replace("maximumFileBytes: 1073741824", "maximumFileBytes: 1024")), - ConfigError::Invalid("audit maximumFileBytes must be 1048576..=1099511627776") - ); - assert_eq!( - load_error(&VALID.replace( - "path: audit/mint.jsonl", - "path: /run/registry-mint/secrets/audit-hmac-key" - )), - ConfigError::Invalid( - "audit storage, audit key, and local signing material must be distinct" - ) - ); - } - - #[test] - fn a_plain_absolute_audit_path_is_accepted_outside_the_configuration_directory() { - let storage = tempfile::tempdir().expect("audit storage"); - let destination = storage.path().join("audit").join("mint.jsonl"); - let config = load_from(&VALID.replace( - "path: audit/mint.jsonl", - &format!("path: {}", destination.display()), - )) - .expect("an absolute audit destination names storage the operator chose"); - - assert_eq!( - config.audit.path, - storage - .path() - .canonicalize() - .expect("canonical audit storage") - .join("audit/mint.jsonl") - ); - } - - #[test] - fn an_audit_destination_that_already_exists_stays_openable() { - // A restart resolves a chain that is already there, so nothing is left - // to append to the resolved location. The recorded path still has to - // name the file: a trailing separator would name a directory instead. - let storage = tempfile::tempdir().expect("audit storage"); - let destination = storage.path().join("mint.jsonl"); - fs::write(&destination, "").expect("stage an existing audit chain"); - let config = load_from(&VALID.replace( - "path: audit/mint.jsonl", - &format!("path: {}", destination.display()), - )) - .expect("an existing audit destination is valid"); - - fs::OpenOptions::new() - .append(true) - .open(&config.audit.path) - .expect("the recorded destination opens for append"); - } - - #[cfg(unix)] - #[test] - fn an_absolute_audit_path_records_where_its_links_resolve() { - // The deployment that mounts the storage is not the only place a link - // in the middle of the destination gets resolved: Mint records the - // location it will actually open. - let storage = tempfile::tempdir().expect("audit storage"); - let linked = tempfile::tempdir().expect("linked audit storage"); - std::os::unix::fs::symlink(linked.path(), storage.path().join("audit")) - .expect("audit directory link"); - let config = load_from(&VALID.replace( - "path: audit/mint.jsonl", - &format!( - "path: {}", - storage.path().join("audit").join("mint.jsonl").display() - ), - )) - .expect("an absolute audit destination is valid"); - - assert_eq!( - config.audit.path, - linked - .path() - .canonicalize() - .expect("canonical linked storage") - .join("mint.jsonl") - ); - } - - #[cfg(unix)] - #[test] - fn an_audit_path_whose_link_leaves_the_configuration_directory_is_refused() { - // A relative destination names a file under the configuration - // directory. A link component that leads out of it would move the - // audit chain somewhere the configuration never named. - let directory = tempfile::tempdir().expect("temp dir"); - let elsewhere = tempfile::tempdir().expect("ephemeral storage"); - std::os::unix::fs::symlink(elsewhere.path(), directory.path().join("audit")) - .expect("escaping symlink"); - let path = directory.path().join("mint.yaml"); - fs::write(&path, VALID).expect("write config"); - - assert_eq!( - MintConfig::load(&path).expect_err("the destination must be rejected"), - ConfigError::Invalid("audit path must resolve inside the configuration directory") - ); - } - - #[cfg(unix)] - #[test] - fn a_dangling_audit_path_link_fails_closed() { - let directory = tempfile::tempdir().expect("temp dir"); - fs::create_dir(directory.path().join("audit")).expect("audit directory"); - std::os::unix::fs::symlink( - directory.path().join("audit").join("missing"), - directory.path().join("audit").join("mint.jsonl"), - ) - .expect("dangling symlink"); - let path = directory.path().join("mint.yaml"); - fs::write(&path, VALID).expect("write config"); - - assert_eq!( - MintConfig::load(&path).expect_err("the destination must be rejected"), - ConfigError::Invalid("audit path could not be resolved to a real location") - ); - } - - #[test] - fn an_audit_path_with_a_parent_traversal_component_is_refused() { - assert_eq!( - load_error(&VALID.replace("path: audit/mint.jsonl", "path: audit/../../mint.jsonl")), - ConfigError::Invalid("audit path must be absolute and free of parent traversal") - ); - } - - #[test] - fn a_jwks_path_may_not_take_a_route_mint_already_serves() { - for path in MINT_FIXED_ROUTES { - let text = VALID.replace( - "activePublicJwkFile: public-keys/mint.jwk.json", - &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: {path}"), - ); - assert_eq!( - load_error(&text), - ConfigError::Invalid("jwks path must not take a route Mint already serves"), - "jwks path {path} must be rejected" - ); - } - } - - #[test] - fn a_jwks_path_must_be_a_path_a_client_can_fetch() { - // The path is registered as a route and published as `jwks_uri`. A - // query or fragment survives neither trip: the router matches the - // literal string, and a client sends only the path back. A route - // pattern is worse, because it matches paths the metadata never named. - for path in [ - "/keys?tenant=a", - "/keys#v1", - "/keys/{tenant}", - "/{*rest}", - "/keys//v1", - "/keys/", - "/keys/../token", - "/keys/.", - "/keys v1", - "/keys%2ftoken", - ] { - let text = VALID.replace( - "activePublicJwkFile: public-keys/mint.jwk.json", - &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: \"{path}\""), - ); - assert_eq!( - load_error(&text), - ConfigError::Invalid("jwks path must be a plain absolute path with no query, fragment, or route pattern"), - "jwks path {path} must be rejected" - ); - } - } - - #[test] - fn a_plain_absolute_jwks_path_is_accepted() { - for path in [ - "/.well-known/jwks.json", - "/keys", - "/v1/keys.json", - "/a~b-c_d", - ] { - let text = VALID.replace( - "activePublicJwkFile: public-keys/mint.jwk.json", - &format!("activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: \"{path}\""), - ); - let config = load_from(&text).expect("a plain absolute path loads"); - assert_eq!(config.signing.jwks_path, path); - } - } - - #[test] - fn listener_limits_must_admit_a_request() { - for (field, value) in [ - ("maximumRequestBytes", 0), - ("maximumRequestBytes", 1_048_577), - ("requestTimeoutMilliseconds", 0), - ("requestTimeoutMilliseconds", 30_001), - ] { - let text = VALID.replace( - "listener: {address: 127.0.0.1, port: 8081}", - &format!("listener: {{address: 127.0.0.1, port: 8081, {field}: {value}}}"), - ); - assert!( - matches!(load_from(&text), Err(ConfigError::Invalid(_))), - "{field} {value} must be rejected" - ); - } - } - - #[test] - fn unknown_fields_are_rejected() { - let text = VALID.replace("version: 1", "version: 1\nunexpected: true"); - assert!(matches!(load_from(&text), Err(ConfigError::Document(_)))); - } - - #[test] - fn issuers_must_be_https_hosts_without_credentials_or_query() { - for issuer in [ - "http://mint.example.org", - "https://user:pass@mint.example.org", - "https://mint.example.org?tenant=a", - "https://mint.example.org#frag", - "mint.example.org", - "https://", - ] { - let text = VALID.replace("https://mint.example.org\n", &format!("{issuer}\n")); - assert!( - matches!(load_from(&text), Err(ConfigError::Invalid(_))), - "issuer {issuer} must be rejected" - ); - } - } - - #[test] - fn supervised_local_development_accepts_only_the_exact_mint_transport() { - let local = VALID - .replace( - "version: 1", - "version: 1\nvalidationMode: supervised-local-development", - ) - .replace( - "issuer: https://mint.example.org", - "issuer: http://127.0.0.1:8081", - ) - .replace( - "audience: https://mint.example.org/token", - "audience: http://127.0.0.1:8081/token", - ) - .replace( - "signer:\n kind: transit\n unixSocketPath: /run/registry-mint/transit-proxy.sock\n mount: transit\n keyName: mint-signing\n keyVersion: 7\n timeoutMilliseconds: 2000", - "signer:\n kind: local-jwk\n privateKeyRef: secret:file/mint-signing", - ); - let config = load_from(&local).expect("the supervised local transport is valid"); - assert_eq!( - config.validation_mode, - ValidationMode::SupervisedLocalDevelopment - ); - - for port in [1_u16, u16::MAX] { - let boundary = local.replace("8081", &port.to_string()); - load_from(&boundary).unwrap_or_else(|error| { - panic!("canonical boundary port {port} must be accepted: {error}") - }); - } - - for invalid_issuer in [ - "http://localhost:8081", - "http://[::1]:8081", - "http://127.0.0.2:8081", - "http://127.00.0.1:8081", - "http://127.0.0.1", - "http://127.0.0.1:0", - "http://127.0.0.1:08081", - "http://127.0.0.1:65536", - "http://user@127.0.0.1:8081", - "http://127.0.0.1:8081/", - "http://127.0.0.1:8081?tenant=x", - "http://127.0.0.1:8081#fragment", - "https://127.0.0.1:8081", - ] { - let invalid = local.replace( - "issuer: http://127.0.0.1:8081", - &format!("issuer: {invalid_issuer}"), - ); - assert!( - load_from(&invalid).is_err(), - "accepted supervised local issuer {invalid_issuer}" - ); - } - - for invalid_audience in [ - "http://127.0.0.1:8081", - "http://127.0.0.1:8081/token/", - "http://127.0.0.1:8081/TOKEN", - "http://127.0.0.1:8081/oauth/token", - "http://127.0.0.1:8081/token?tenant=x", - "http://127.0.0.1:8081/token#fragment", - "http://127.0.0.1:8082/token", - ] { - let invalid = local.replace( - "audience: http://127.0.0.1:8081/token", - &format!("audience: {invalid_audience}"), - ); - assert!( - load_from(&invalid).is_err(), - "accepted supervised local assertion audience {invalid_audience}" - ); - } - - for replacement in [ - "listener: {address: 127.0.0.2, port: 8081}", - "listener: {address: 127.0.0.1, port: 8082}", - "listener: {address: 127.0.0.1, port: 0}", - ] { - let invalid = local.replace("listener: {address: 127.0.0.1, port: 8081}", replacement); - assert!( - load_from(&invalid).is_err(), - "accepted mismatched listener {replacement}" - ); - } - - let wrong_jwks = local.replace( - "activePublicJwkFile: public-keys/mint.jwk.json", - "activePublicJwkFile: public-keys/mint.jwk.json\n jwksPath: /.well-known/keys.json", - ); - assert!( - load_from(&wrong_jwks).is_err(), - "accepted a non-Mint JWKS path" - ); - } - - #[test] - fn strict_mode_is_the_https_only_default() { - let default = load_from(VALID).expect("the existing strict document remains valid"); - assert_eq!(default.validation_mode, ValidationMode::Strict); - - let explicit = VALID.replace("version: 1", "version: 1\nvalidationMode: strict"); - assert_eq!( - load_from(&explicit) - .expect("the explicit strict mode is valid") - .validation_mode, - ValidationMode::Strict - ); - - let local_without_mode = VALID - .replace( - "issuer: https://mint.example.org", - "issuer: http://127.0.0.1:8081", - ) - .replace( - "audience: https://mint.example.org/token", - "audience: http://127.0.0.1:8081/token", - ); - assert!( - load_from(&local_without_mode).is_err(), - "strict Mint inherited the local HTTP exception" - ); - - for invalid_audience in [ - "http://127.0.0.1:8081/token", - "https://user:pass@mint.example.org/token", - "https://mint.example.org/token?tenant=x", - "https://mint.example.org/token#fragment", - "mint.example.org/token", - "https://", - ] { - let invalid = VALID.replace( - "audience: https://mint.example.org/token", - &format!("audience: {invalid_audience}"), - ); - assert!( - load_from(&invalid).is_err(), - "strict Mint accepted assertion audience {invalid_audience}" - ); - } - } - - #[test] - fn signer_kind_follows_the_assurance_matrix() { - let strict_local = VALID.replace( - "signer:\n kind: transit\n unixSocketPath: /run/registry-mint/transit-proxy.sock\n mount: transit\n keyName: mint-signing\n keyVersion: 7\n timeoutMilliseconds: 2000", - "signer:\n kind: local-jwk\n privateKeyRef: secret:file/mint-signing", - ); - assert_eq!( - load_error(&strict_local), - ConfigError::Invalid("strict mode requires a Transit signer") - ); - - let supervised_transit = VALID - .replace( - "version: 1", - "version: 1\nvalidationMode: supervised-local-development", - ) - .replace( - "issuer: https://mint.example.org", - "issuer: http://127.0.0.1:8081", - ) - .replace( - "audience: https://mint.example.org/token", - "audience: http://127.0.0.1:8081/token", - ); - load_from(&supervised_transit).expect("supervised local mode also permits Transit"); - } - - #[test] - fn service_signing_is_fixed_to_es256_and_thumbprint_revocations() { - assert_eq!( - load_error(&VALID.replace("algorithm: ES256", "algorithm: EdDSA")), - ConfigError::Invalid("Mint service signing algorithm must be ES256") - ); - let noncanonical = format!("{}B", "A".repeat(42)); - for revoked in [ - "short", - "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!", - &noncanonical, - ] { - let document = - VALID.replace("revokedKeyIds: []", &format!("revokedKeyIds: [{revoked}]")); - assert!( - load_from(&document).is_err(), - "accepted revoked id {revoked}" - ); - } - } - - #[test] - fn access_token_lifetime_is_bounded_on_both_sides() { - for lifetime in ["1", "59", "3601", "86400"] { - let text = VALID.replace( - "lifetimeSeconds: 300", - &format!("lifetimeSeconds: {lifetime}"), - ); - assert_eq!( - load_error(&text), - ConfigError::Invalid("access token lifetime must be 60..=3600 seconds"), - "lifetime {lifetime} must be rejected" - ); - } - } - - #[test] - fn duplicate_claim_names_are_rejected() { - let text = VALID.replace("grantId: evidence_grant_id", "grantId: evidence_tags"); - assert_eq!( - load_error(&text), - ConfigError::Invalid("claim names must be distinct") - ); - } - - #[test] - fn evidence_claim_names_are_optional_for_scoped_only_deployments() { - let without_claims = VALID.replace( - " claims:\n principal: sub\n requesterTags: evidence_tags\n evidenceAudience: evidence_audience\n grantId: evidence_grant_id\n grantAuthority: evidence_authority\n", - "", - ); - let config = load_from(&without_claims).expect("a scoped-only token policy loads"); - assert!(config.access_tokens.claims.is_none()); - } - - #[test] - fn authority_claims_cannot_shadow_registered_jwt_claims() { - for reserved in ["iss", "aud", "exp", "jti", "client_id", "scope"] { - let text = VALID.replace( - "requesterTags: evidence_tags", - &format!("requesterTags: {reserved}"), - ); - assert_eq!( - load_error(&text), - ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), - "claim {reserved} must be rejected" - ); - } - } - - #[test] - fn the_principal_claim_is_bound_by_the_same_rule_as_the_others() { - // Minting writes the registered claims after the JWT ones, so a - // principal named for a reserved claim would overwrite it. `aud` is the - // one that matters most: the token would carry the principal as its - // audience and still verify. - for reserved in [ - "iss", - "aud", - "exp", - "iat", - "nbf", - "jti", - "client_id", - "scope", - ] { - let text = VALID.replace("principal: sub", &format!("principal: {reserved}")); - assert_eq!( - load_error(&text), - ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), - "principal {reserved} must be rejected" - ); - } - } - - #[test] - fn only_the_principal_may_be_named_sub() { - // `sub` always carries the principal, so naming the principal claim - // `sub` is the default and merely rewrites the same value. - load_from(VALID).expect("principal may be named sub"); - - // Any other claim named `sub` would replace the principal with its own - // value, which for requester tags is not even a string. The principal - // moves off `sub` first, so this is the shadowing rule answering rather - // than the distinctness rule. - let renamed = VALID.replace("principal: sub", "principal: evidence_principal"); - for field in [ - "requesterTags: evidence_tags", - "evidenceAudience: evidence_audience", - "grantId: evidence_grant_id", - "grantAuthority: evidence_authority", - ] { - let name = field.split(':').next().expect("a claim field name"); - let text = renamed.replace(field, &format!("{name}: sub")); - assert_eq!( - load_error(&text), - ConfigError::Invalid("authority claim names must not shadow registered JWT claims"), - "{name} must not be named sub" - ); - } - } - - #[test] - fn the_actor_claim_is_optional_and_obeys_every_other_claim_name_rule() { - // A deployment that never delegates names no actor claim at all. - assert!(sample_config() - .access_tokens - .claims - .as_ref() - .expect("the Evidence sample names its claims") - .actor - .is_none()); - - let with_actor = |name: &str| { - VALID.replace( - "grantAuthority: evidence_authority", - &format!("grantAuthority: evidence_authority\n actor: {name}"), - ) - }; - - let config = load_from(&with_actor("evidence_actor")).expect("an actor claim is accepted"); - assert_eq!( - config - .access_tokens - .claims - .as_ref() - .expect("the Evidence configuration names its claims") - .actor - .as_deref(), - Some("evidence_actor") - ); - - // Reusing another authority claim would let the actor overwrite it. - assert_eq!( - load_error(&with_actor("evidence_tags")), - ConfigError::Invalid("claim names must be distinct") - ); - assert_eq!( - load_error(&with_actor("client_id")), - ConfigError::Invalid("authority claim names must not shadow registered JWT claims") - ); - assert_eq!( - load_error(&with_actor("\"\"")), - ConfigError::Invalid("claim names must be 1..=128 bytes") - ); - } - - #[test] - fn version_must_be_one_and_audiences_must_be_present() { - let text = VALID.replace("version: 1", "version: 2"); - assert_eq!( - load_error(&text), - ConfigError::Invalid("only configuration version 1 is supported") - ); - - let text = VALID.replace("audiences: [evidence]", "audiences: []"); - assert_eq!( - load_error(&text), - ConfigError::Invalid("between 1 and 16 audiences are required") - ); - } -} diff --git a/crates/registry-mint/src/error.rs b/crates/registry-mint/src/error.rs deleted file mode 100644 index b9939f3e96..0000000000 --- a/crates/registry-mint/src/error.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! OAuth 2.0 token endpoint errors. -//! -//! The public error code is deliberately coarse. Whether a client is unknown, -//! used the wrong authentication method, presented a bad signature or secret, -//! replayed a `jti`, or sent an expired assertion, the caller sees -//! `invalid_client`. Distinguishing those cases on the wire would turn the token -//! endpoint into an oracle for probing the client registry. The specific reason -//! is retained for operator logs only. - -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use serde::Serialize; - -/// RFC 6749 section 5.2 error codes, restricted to the ones Mint can return. -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum TokenErrorCode { - InvalidRequest, - InvalidClient, - UnsupportedGrantType, - ServerError, -} - -impl TokenErrorCode { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::InvalidRequest => "invalid_request", - Self::InvalidClient => "invalid_client", - Self::UnsupportedGrantType => "unsupported_grant_type", - Self::ServerError => "server_error", - } - } - - #[must_use] - pub fn status(self) -> StatusCode { - match self { - Self::InvalidRequest | Self::UnsupportedGrantType => StatusCode::BAD_REQUEST, - Self::InvalidClient => StatusCode::UNAUTHORIZED, - Self::ServerError => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -#[derive(Debug, Serialize)] -struct TokenErrorBody { - error: &'static str, -} - -/// A token endpoint failure carrying a public code and a private reason. -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct TokenError { - code: TokenErrorCode, - reason: &'static str, -} - -impl TokenError { - #[must_use] - pub fn new(code: TokenErrorCode, reason: &'static str) -> Self { - Self { code, reason } - } - - #[must_use] - pub fn invalid_request(reason: &'static str) -> Self { - Self::new(TokenErrorCode::InvalidRequest, reason) - } - - /// Every client authentication failure collapses to this variant. - #[must_use] - pub fn invalid_client(reason: &'static str) -> Self { - Self::new(TokenErrorCode::InvalidClient, reason) - } - - #[must_use] - pub fn unsupported_grant_type(reason: &'static str) -> Self { - Self::new(TokenErrorCode::UnsupportedGrantType, reason) - } - - #[must_use] - pub fn server_error(reason: &'static str) -> Self { - Self::new(TokenErrorCode::ServerError, reason) - } - - #[must_use] - pub fn code(&self) -> TokenErrorCode { - self.code - } - - /// Operator-facing detail. Never sent to the caller. - #[must_use] - pub fn reason(&self) -> &'static str { - self.reason - } - - /// Render a token-operation failure with its privacy-safe correlation id. - #[must_use] - pub fn into_operation_response(self, operation: &str) -> Response { - self.respond(Some(operation)) - } - - fn respond(self, operation: Option<&str>) -> Response { - tracing::warn!( - target: "registry_mint::token", - operation, - error = self.code.as_str(), - reason = self.reason, - "token request rejected" - ); - ( - self.code.status(), - Json(TokenErrorBody { - error: self.code.as_str(), - }), - ) - .into_response() - } -} - -impl IntoResponse for TokenError { - fn into_response(self) -> Response { - self.respond(None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_authentication_failures_share_one_public_code() { - for reason in [ - "unknown client", - "signature did not verify", - "client secret did not verify", - "assertion replayed", - "assertion expired", - ] { - let error = TokenError::invalid_client(reason); - assert_eq!(error.code().as_str(), "invalid_client"); - assert_eq!(error.code().status(), StatusCode::UNAUTHORIZED); - } - } - - #[test] - fn public_codes_match_the_oauth_registry() { - assert_eq!(TokenErrorCode::InvalidRequest.as_str(), "invalid_request"); - assert_eq!(TokenErrorCode::InvalidClient.as_str(), "invalid_client"); - assert_eq!( - TokenErrorCode::UnsupportedGrantType.as_str(), - "unsupported_grant_type" - ); - assert_eq!(TokenErrorCode::ServerError.as_str(), "server_error"); - } -} diff --git a/crates/registry-mint/src/lib.rs b/crates/registry-mint/src/lib.rs deleted file mode 100644 index 65a11efe82..0000000000 --- a/crates/registry-mint/src/lib.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Registry Mint: a minimal OAuth 2.0 token issuer for RegistryStack services. -//! -//! Mint exists to answer one question that a JWKS alone cannot answer: *which -//! principal signed this token, and what is that principal allowed to assert?* -//! -//! A resource server such as Evidence Gateway or Registry Relay verifies an -//! access token by selecting a key from a JWKS using the token's own `kid` -//! header, then reading the authority claims out of the payload. Nothing in -//! that flow binds a key to a permitted claim set, so every key published in a -//! JWKS is equally authoritative for every claim. Distributing signing keys -//! directly to callers therefore makes each caller an issuer able to speak as -//! any other. -//! -//! Mint keeps that binding server-side. Callers normally hold their own private -//! keys and authenticate with an RFC 7523 `private_key_jwt` client assertion. -//! A managed client that cannot sign assertions may instead use an explicitly -//! registered high-entropy client secret. Mint authenticates only with the -//! method and credential registered for that client, then mints an access token -//! whose authority claims come from the server-side registry and never from the -//! request. A caller can prove which client it is; it cannot choose what it is -//! allowed to say. -//! -//! # Trust split -//! -//! - Issuer identity, signing and audit keys, listener, and token policy are startup-only -//! and immutable for the process lifetime. -//! - The client registry is reloadable, so onboarding, offboarding, and key -//! rotation for callers never require restarting a resource server. -//! -//! That split is the point of running Mint as a separate process: resource -//! servers keep their immutable governed contracts while the caller population -//! changes. -//! -//! # Naming -//! -//! "Issuer" here means OAuth token issuance. It is unrelated to the verifiable -//! credential issuance performed by Registry Notary. - -#[cfg(not(unix))] -compile_error!( - "registry-mint requires a Unix target for owner-only signing and audit file guarantees" -); - -pub mod assertion; -pub mod audit; -pub mod caller; -#[doc(hidden)] -pub mod cli; -pub mod client_secret; -pub mod clients; -pub mod config; -pub mod error; -pub mod replay; -pub mod secretfile; -pub mod server; -pub mod token; - -pub use cli::command; - -/// RFC 7523 client assertion type for `private_key_jwt` authentication. -pub const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - -/// The only grant type Mint issues tokens for. -pub const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; - -/// Media type of the minted access tokens. -pub const ACCESS_TOKEN_TYP: &str = "at+jwt"; - -/// The client assertion member naming the actor and subject a delegated token -/// is requested for. -/// -/// This is Mint's own member, not RFC 8693 `act`: token exchange presents a -/// subject's own credential, which is precisely what a deployment without an -/// identity provider does not have. -pub const ON_BEHALF_OF_CLAIM: &str = "on_behalf_of"; diff --git a/crates/registry-mint/src/main.rs b/crates/registry-mint/src/main.rs deleted file mode 100644 index 74cfb4cc6f..0000000000 --- a/crates/registry-mint/src/main.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! The `mint` binary. -//! -//! `check` validates a deployment without opening a socket and `serve` runs the -//! token endpoint; `SIGHUP` reloads the client registry in place so onboarding -//! a caller never restarts the service. `client-secret` provisions compatible -//! managed clients without printing their raw credential. -//! -//! `token` is the odd one out: it is a *caller* tool, not an operator one. It -//! reads no server configuration and never touches Mint's signing key. It signs -//! a client assertion with the caller's own key and presents it to a running -//! token endpoint, which then decides on its own terms. Obtaining a token still -//! requires authenticating, in the CLI exactly as over the wire. - -use std::{collections::BTreeMap, path::Path, process::ExitCode, sync::Arc}; - -use clap::Parser; -use registry_mint::cli::{Cli, ClientSecretCommand, Command}; -use registry_mint::{ - audit::MintAuditLog, - caller::{sign_client_assertion, AssertionRequest}, - client_secret, - config::MintConfig, - secretfile, - server::{healthcheck, serve, MintService}, - CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, -}; -use registry_platform_audit::OptionalHashHex; -use serde_json::Value; - -fn main() -> ExitCode { - let cli = Cli::parse(); - - // `token` writes the access token to stdout and nothing else, so its - // diagnostics go to stderr and the caller can pipe the token straight into - // whatever needs it. The services keep structured logs on stdout. - let logs = tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .json(); - if matches!( - cli.command, - Command::Token { .. } | Command::ClientSecret { .. } | Command::Healthcheck { .. } - ) { - logs.with_writer(std::io::stderr).init(); - } else { - logs.init(); - } - - match run(cli) { - Ok(()) => ExitCode::SUCCESS, - Err(message) => { - // Startup failures name the failing stage, never the key material - // or the file contents that produced them. - tracing::error!(target: "registry_mint", "{message}"); - ExitCode::FAILURE - } - } -} - -fn run(cli: Cli) -> Result<(), String> { - match cli.command { - Command::Check { - config, - require_runtime_dependencies, - require_audit_under: audit_root, - } => { - let config = MintConfig::load(&config) - .map_err(|error| format!("the configuration could not be loaded: {error}"))?; - // The deployment owns storage persistence and declares the root it - // mounts; Mint owns where the sink resolves. Proving containment - // before the writer is claimed keeps the two boundaries separate - // and never relaxes the readiness proof below. - if let Some(root) = audit_root.as_deref() { - registry_platform_audit::require_audit_under(&config.audit.path, root) - .map_err(|fault| format!("the audit destination check failed: {fault}"))?; - } - let issuer = config.issuer.clone(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| format!("the async runtime could not start: {error}"))?; - let clients = if require_runtime_dependencies { - let service = runtime - .block_on(MintService::load(config)) - .map_err(|error| format!("the runtime dependencies are not ready: {error}"))?; - if !runtime.block_on(service.ready()) { - return Err("the runtime dependencies are not ready".to_owned()); - } - service.client_count() - } else { - runtime - .block_on(MintService::check(&config)) - .map_err(|error| format!("the configuration cannot be served: {error}"))? - }; - tracing::info!( - target: "registry_mint", - issuer, - clients, - "configuration is valid" - ); - Ok(()) - } - Command::Healthcheck { url } => { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|_| "the readiness probe failed".to_owned())?; - runtime - .block_on(healthcheck(&url)) - .map_err(|_| "the readiness probe failed".to_owned()) - } - Command::Serve { config } => { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| format!("the async runtime could not start: {error}"))?; - runtime.block_on(async move { - let service = Arc::new(load(&config).await?); - let reloads = Arc::clone(&service); - tokio::spawn(async move { reload_on_hangup(reloads).await }); - serve(service, shutdown_signal()) - .await - .map_err(|error| format!("the listener failed: {error}")) - }) - } - Command::VerifyAudit { config } => { - let config = MintConfig::load(&config) - .map_err(|error| format!("the configuration could not be loaded: {error}"))?; - let summary = MintAuditLog::verify(&config.audit, &config.secret_providers) - .map_err(|error| format!("the audit chain did not verify: {error}"))?; - let sealed_sequence = match (summary.first_sequence, summary.last_sequence) { - (Some(first), Some(last)) => format!("{first}-{last}"), - _ => "none".to_owned(), - }; - let active_segment = if summary.active_verified { - "verified" - } else { - "not verified: a running writer holds it, so only sealed history was proven" - }; - println!( - "segments: {}\nrecords: {}\nsealed-sequence: {}\nhead: {}\nactive-segment: {}", - summary.segments, - summary.records, - sealed_sequence, - OptionalHashHex(summary.last_hash), - active_segment, - ); - Ok(()) - } - Command::ClientSecret { command } => match command { - ClientSecretCommand::Generate { out } => { - let fingerprint = client_secret::generate(&out).map_err(|error| { - format!("the client secret could not be generated: {error}") - })?; - println!("{fingerprint}"); - Ok(()) - } - }, - Command::Token { - url, - client_id, - key, - audience, - actor, - subject_file, - lifetime_seconds, - ca_certificate, - verbose, - } => { - // The caller's key gets the same file guarantees as Mint's own: - // a regular file, owned by this user, unreadable by anyone else, - // and reached without traversing a symlink. - let key = secretfile::read_owner_only(&key) - .map_err(|error| format!("the client key could not be read: {error}"))?; - let key = registry_platform_crypto::PrivateJwk::parse(&key) - .map_err(|error| format!("the client key is not a usable private JWK: {error}"))?; - - let subject = subject_file.as_deref().map(read_subject).transpose()?; - let assertion = sign_client_assertion( - &key, - &AssertionRequest { - client_id: &client_id, - audience: audience.as_deref().unwrap_or(&url), - lifetime_seconds, - actor: actor.as_deref(), - subject, - }, - now_seconds()?, - ) - .map_err(|error| format!("the client assertion could not be built: {error}"))?; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| format!("the async runtime could not start: {error}"))?; - let response = - runtime.block_on(request_token(&url, &assertion, ca_certificate.as_deref()))?; - - if verbose { - println!("{response}"); - } else { - let token = response - .get("access_token") - .and_then(Value::as_str) - .ok_or_else(|| "the endpoint returned no access token".to_owned())?; - println!("{token}"); - } - Ok(()) - } - } -} - -/// Read the delegation subject: a flat JSON object of selector fields. -fn read_subject(path: &Path) -> Result, String> { - let bytes = std::fs::read(path) - .map_err(|error| format!("the subject file could not be read: {error}"))?; - let subject: Value = serde_json::from_slice(&bytes) - .map_err(|error| format!("the subject file is not JSON: {error}"))?; - let Value::Object(fields) = subject else { - return Err("the subject file must hold a JSON object of selector fields".to_owned()); - }; - // Selector values are scalars. Rejecting anything else here names the - // problem, where the endpoint could only answer `invalid_client`. - for (name, value) in &fields { - if value.is_object() || value.is_array() || value.is_null() { - return Err(format!("the subject field `{name}` must be a scalar value")); - } - } - Ok(fields.into_iter().collect()) -} - -async fn request_token( - url: &str, - assertion: &str, - ca_certificate: Option<&Path>, -) -> Result { - let mut client = reqwest::Client::builder(); - if let Some(path) = ca_certificate { - let pem = std::fs::read(path) - .map_err(|error| format!("the CA certificate could not be read: {error}"))?; - for certificate in reqwest::Certificate::from_pem_bundle(&pem) - .map_err(|error| format!("the CA certificate could not be parsed: {error}"))? - { - client = client.add_root_certificate(certificate); - } - } - let client = client - .build() - .map_err(|error| format!("the HTTP client could not be built: {error}"))?; - - let response = client - .post(url) - .form(&[ - ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), - ("client_assertion_type", CLIENT_ASSERTION_TYPE), - ("client_assertion", assertion), - ]) - .send() - .await - .map_err(|error| format!("the token request failed: {error}"))?; - - let status = response.status(); - let body = response - .text() - .await - .map_err(|error| format!("the token response could not be read: {error}"))?; - if !status.is_success() { - // The request carried a signed client assertion, which is a bearer - // credential at the endpoint it is bound to until it expires. Whatever - // answered here is not necessarily that endpoint, and a refusal body is - // free to quote the form back. Report the two bounded OAuth fields and - // drop the rest rather than write the assertion into stderr, the - // operator's logs, and their scrollback. - return Err(format!( - "the endpoint refused the request ({status}): {}", - oauth_error(&body) - )); - } - serde_json::from_str(&body).map_err(|error| format!("the token response is not JSON: {error}")) -} - -/// Summarize a refusal using only the two fields RFC 6749 defines for one. -/// -/// Both are reproduced as printable ASCII within the length the RFC's own -/// grammar allows, so a hostile or merely careless endpoint cannot use the -/// refusal to write arbitrary bytes, control sequences, or the caller's own -/// request into the terminal. -fn oauth_error(body: &str) -> String { - let Ok(Value::Object(fields)) = serde_json::from_str::(body) else { - return "the response carried no OAuth error".to_owned(); - }; - let field = |name: &str| -> Option { - let value = fields.get(name)?.as_str()?; - let bounded: String = value - .chars() - .filter(|character| { - character.is_ascii_graphic() || *character == ' ' || *character == '\t' - }) - .take(200) - .collect(); - (!bounded.is_empty()).then_some(bounded) - }; - match (field("error"), field("error_description")) { - (Some(error), Some(description)) => format!("{error}: {description}"), - (Some(error), None) => error, - _ => "the response carried no OAuth error".to_owned(), - } -} - -fn now_seconds() -> Result { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs() as i64) - .map_err(|_| "the system clock is before the Unix epoch".to_owned()) -} - -async fn load(config: &Path) -> Result { - let config = MintConfig::load(config) - .map_err(|error| format!("the configuration could not be loaded: {error}"))?; - MintService::load(config) - .await - .map_err(|error| format!("the service could not start: {error}")) -} - -/// Reload the client registry on every `SIGHUP`, keeping the previous registry -/// when the new one does not load. -async fn reload_on_hangup(service: Arc) { - let mut hangup = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) { - Ok(hangup) => hangup, - Err(error) => { - tracing::error!(target: "registry_mint", "the hangup handler could not be installed: {error}"); - return; - } - }; - while hangup.recv().await.is_some() { - match service.reload_clients() { - Ok(clients) => { - tracing::info!(target: "registry_mint", clients, "client registry reloaded"); - } - Err(error) => { - tracing::error!( - target: "registry_mint", - "the client registry was not reloaded and the previous one is still in use: {error}" - ); - } - } - } -} - -async fn shutdown_signal() { - let interrupt = async { - let _ = tokio::signal::ctrl_c().await; - }; - let terminate = async { - match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { - Ok(mut terminate) => { - terminate.recv().await; - } - Err(_) => std::future::pending::<()>().await, - } - }; - tokio::select! { - () = interrupt => {} - () = terminate => {} - } -} diff --git a/crates/registry-mint/src/replay.rs b/crates/registry-mint/src/replay.rs deleted file mode 100644 index c5b77aa1c2..0000000000 --- a/crates/registry-mint/src/replay.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! Single-use enforcement for client assertion `jti` values. -//! -//! A captured client assertion is a bearer credential until it expires. The -//! cache remembers every accepted `jti` until its own expiry, so a captured -//! assertion buys an attacker nothing. - -use std::{ - collections::{BTreeMap, HashSet}, - sync::{Arc, Mutex}, -}; - -use thiserror::Error; - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum ReplayError { - #[error("the assertion identifier has already been used")] - AlreadyUsed, - #[error("the replay cache is saturated")] - Saturated, - #[error("the replay cache is poisoned")] - Poisoned, -} - -/// A bounded set of assertion identifiers that have already been spent. -#[derive(Debug)] -pub struct ReplayCache { - capacity: usize, - entries: Mutex, -} - -#[derive(Debug, Default)] -struct ReplayEntries { - identifiers: HashSet>, - /// One entry per remembered identifier, grouped by its exact expiry. - /// Sharing the identifier avoids copying its bytes into the expiry index. - expirations: BTreeMap>>, -} - -impl ReplayCache { - #[must_use] - pub fn new(capacity: usize) -> Self { - Self { - capacity, - entries: Mutex::new(ReplayEntries::default()), - } - } - - /// Record `jti` as spent until `expires_at`. - /// - /// Saturation fails closed rather than evicting a live entry. Evicting the - /// oldest entry would let a caller flush the cache with fresh assertions - /// and then replay the one it evicted, which is precisely what this cache - /// exists to prevent. Only clients that already passed signature - /// verification can reach this code, so the failure is bounded to - /// authenticated misbehaviour and is visible to operators. - pub fn remember(&self, jti: &str, expires_at: i64, now: i64) -> Result<(), ReplayError> { - let mut entries = self.entries.lock().map_err(|_| ReplayError::Poisoned)?; - // Inspect only expired buckets, so live replays and saturation do not - // scan every remembered assertion while holding the shared mutex. - while entries - .expirations - .first_key_value() - .is_some_and(|(expiry, _)| *expiry <= now) - { - let (_, identifiers) = entries - .expirations - .pop_first() - .expect("an expired bucket is present"); - if entries.expirations.is_empty() { - // The last expired bucket covers every remaining identifier; - // clearing it avoids rehashing the entire cohort for removal. - entries.identifiers.clear(); - } else { - for identifier in identifiers { - entries.identifiers.remove(identifier.as_ref()); - } - } - } - if entries.identifiers.contains(jti) { - return Err(ReplayError::AlreadyUsed); - } - if entries.identifiers.len() >= self.capacity { - return Err(ReplayError::Saturated); - } - let identifier: Arc = Arc::from(jti); - entries.identifiers.insert(Arc::clone(&identifier)); - entries - .expirations - .entry(expires_at) - .or_default() - .push(identifier); - Ok(()) - } - - #[must_use] - pub fn len(&self) -> usize { - self.entries - .lock() - .map(|entries| entries.identifiers.len()) - .unwrap_or(0) - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{collections::HashMap, sync::Barrier, thread}; - - #[test] - fn a_fresh_identifier_is_accepted_once_and_then_refused() { - let cache = ReplayCache::new(16); - assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); - assert_eq!( - cache.remember("jti-1", 100, 0), - Err(ReplayError::AlreadyUsed) - ); - } - - #[test] - fn distinct_identifiers_do_not_collide() { - let cache = ReplayCache::new(16); - assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); - assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); - assert_eq!(cache.len(), 2); - } - - #[test] - fn entries_are_pruned_once_their_own_expiry_passes() { - let cache = ReplayCache::new(16); - assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); - // At 101 the assertion is expired anyway, so forgetting it is safe and - // the slot is reclaimed. - assert_eq!(cache.remember("jti-2", 200, 101), Ok(())); - assert_eq!(cache.len(), 1); - } - - #[test] - fn saturation_fails_closed_instead_of_evicting_a_live_entry() { - let cache = ReplayCache::new(2); - assert_eq!(cache.remember("jti-1", 100, 0), Ok(())); - assert_eq!(cache.remember("jti-2", 100, 0), Ok(())); - assert_eq!(cache.remember("jti-3", 100, 0), Err(ReplayError::Saturated)); - // The entry an attacker would have wanted evicted is still remembered. - assert_eq!( - cache.remember("jti-1", 100, 0), - Err(ReplayError::AlreadyUsed) - ); - } - - #[test] - fn expiry_order_is_independent_of_insertion_order() { - let cache = ReplayCache::new(3); - cache.remember("long", 300, 0).unwrap(); - cache.remember("short", 100, 0).unwrap(); - cache.remember("middle", 200, 0).unwrap(); - - assert_eq!( - cache.remember("fresh", 400, 99), - Err(ReplayError::Saturated) - ); - // Exact expiry reclaims the short entry, even though it arrived after - // the long one. Reusing its identifier must acquire the fresh expiry. - assert_eq!(cache.remember("short", 400, 100), Ok(())); - assert_eq!(cache.remember("fresh", 500, 200), Ok(())); - assert_eq!( - cache.remember("short", 500, 300), - Err(ReplayError::AlreadyUsed) - ); - assert_eq!(cache.len(), 2); - assert_eq!(cache.remember("short", 500, 400), Ok(())); - } - - #[test] - fn rejected_attempts_do_not_grow_state_or_change_an_expiry() { - let cache = ReplayCache::new(1); - cache.remember("spent", 100, 0).unwrap(); - for expiry in 101..1_000 { - assert_eq!( - cache.remember("spent", expiry, 0), - Err(ReplayError::AlreadyUsed) - ); - assert_eq!( - cache.remember("unremembered", expiry, 0), - Err(ReplayError::Saturated) - ); - } - { - let entries = cache.entries.lock().unwrap(); - assert_eq!(entries.identifiers.len(), 1); - assert_eq!(entries.expirations.len(), 1); - assert_eq!(entries.expirations[&100].len(), 1); - } - assert_eq!(cache.remember("spent", 200, 100), Ok(())); - } - - #[test] - fn concurrent_calls_accept_an_identifier_exactly_once() { - let cache = ReplayCache::new(16); - let barrier = Barrier::new(16); - let results = thread::scope(|scope| { - let handles: Vec<_> = (0..16) - .map(|_| { - scope.spawn(|| { - barrier.wait(); - cache.remember("shared", 100, 0) - }) - }) - .collect(); - handles - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>() - }); - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!( - results - .iter() - .filter(|result| **result == Err(ReplayError::AlreadyUsed)) - .count(), - 15 - ); - assert_eq!(cache.len(), 1); - } - - #[test] - fn mixed_expiries_and_clock_changes_match_the_full_scan_model() { - for capacity in [0, 1, 16, 256] { - let cache = ReplayCache::new(capacity); - let mut model = HashMap::::new(); - let mut sequence = 1_u64; - for step in 0..10_000 { - // Deterministic synthetic requests include clock backsteps, - // repeated identifiers, saturation, and already expired input. - sequence = sequence - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1); - let now = step / 40 + ((sequence >> 32) % 11) as i64 - 5; - let expiry = now + ((sequence >> 16) % 63) as i64 - 1; - let identifier = format!("jti-{}", (sequence >> 48) % 128); - - model.retain(|_, expiry| *expiry > now); - let expected = if model.contains_key(&identifier) { - Err(ReplayError::AlreadyUsed) - } else if model.len() >= capacity { - Err(ReplayError::Saturated) - } else { - model.insert(identifier.clone(), expiry); - Ok(()) - }; - assert_eq!(cache.remember(&identifier, expiry, now), expected); - assert_eq!(cache.len(), model.len()); - - let entries = cache.entries.lock().unwrap(); - let indexed: usize = entries.expirations.values().map(Vec::len).sum(); - assert_eq!(indexed, model.len()); - assert!(indexed <= capacity); - } - } - } -} diff --git a/crates/registry-mint/src/secretfile.rs b/crates/registry-mint/src/secretfile.rs deleted file mode 100644 index be32bfa6f8..0000000000 --- a/crates/registry-mint/src/secretfile.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Bounded, owner-only reads of private key material. -//! -//! The `mint token` client reads its assertion key here. The serving runtime -//! resolves its signing and audit secrets through the platform secret resolver. - -use std::{fs, io::Read, os::unix::fs::MetadataExt, path::Path}; - -use rustix::fs::{Mode, OFlags}; -use thiserror::Error; -use zeroize::Zeroizing; - -/// Upper bound on a Mint secret file, generous for any supported JWK or HMAC key. -pub const MAX_SECRET_BYTES: u64 = 64 * 1024; - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum SecretFileError { - #[error("the secret file is unavailable")] - Unavailable, - #[error("the secret file is not a regular, single-link, owner-only file")] - Unsafe, - #[error("the secret file is too large")] - TooLarge, - #[error("the secret file could not be read")] - Read, - #[error("the secret file is not valid UTF-8")] - InvalidValue, -} - -/// Read a secret file that must be a regular file, owned by the running user, -/// unreadable by group and other, and not itself a symlink. -/// -/// Open without following a final symlink, then validate and read that same -/// descriptor. Replacing the path after validation cannot substitute another -/// file. The read remains bounded if the opened file grows after validation. -pub fn read_owner_only(path: &Path) -> Result, SecretFileError> { - read_validated_file(open_owner_only(path)?) -} - -fn open_owner_only(path: &Path) -> Result { - let descriptor = rustix::fs::open( - path, - OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, - Mode::empty(), - ) - .map_err(|error| { - if error == rustix::io::Errno::LOOP { - SecretFileError::Unsafe - } else { - SecretFileError::Unavailable - } - })?; - let file = fs::File::from(descriptor); - let metadata = file.metadata().map_err(|_| SecretFileError::Read)?; - if !metadata.is_file() || metadata.nlink() != 1 { - return Err(SecretFileError::Unsafe); - } - if metadata.uid() != rustix::process::geteuid().as_raw() { - return Err(SecretFileError::Unsafe); - } - if metadata.mode() & 0o077 != 0 { - return Err(SecretFileError::Unsafe); - } - if metadata.len() > MAX_SECRET_BYTES { - return Err(SecretFileError::TooLarge); - } - Ok(file) -} - -fn read_validated_file(file: fs::File) -> Result, SecretFileError> { - let mut bytes = Zeroizing::new(Vec::new()); - file.take(MAX_SECRET_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|_| SecretFileError::Read)?; - if bytes.len() as u64 > MAX_SECRET_BYTES { - return Err(SecretFileError::TooLarge); - } - let text = std::str::from_utf8(&bytes).map_err(|_| SecretFileError::InvalidValue)?; - Ok(Zeroizing::new(text.trim().to_owned())) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{io::Write, os::unix::fs::PermissionsExt}; - - fn write_key(directory: &Path, name: &str, mode: u32) -> std::path::PathBuf { - let path = directory.join(name); - let mut file = fs::File::create(&path).expect("create key file"); - file.write_all(b" key-material ").expect("write key file"); - fs::set_permissions(&path, fs::Permissions::from_mode(mode)).expect("set mode"); - path - } - - #[test] - fn owner_only_files_are_read_and_trimmed() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "signing.jwk", 0o600); - let value = read_owner_only(&path).expect("owner-only file reads"); - assert_eq!(&*value, "key-material"); - } - - #[test] - fn group_or_world_readable_files_are_rejected() { - let directory = tempfile::tempdir().expect("temp dir"); - for mode in [0o640, 0o604, 0o644, 0o660] { - let path = write_key(directory.path(), &format!("key-{mode:o}.jwk"), mode); - assert_eq!( - read_owner_only(&path), - Err(SecretFileError::Unsafe), - "mode {mode:o} must be rejected" - ); - } - } - - #[test] - fn symlinked_and_hard_linked_secrets_are_rejected() { - let directory = tempfile::tempdir().expect("temp dir"); - let target = write_key(directory.path(), "target.jwk", 0o600); - - let symlink = directory.path().join("symlink.jwk"); - std::os::unix::fs::symlink(&target, &symlink).expect("create symlink"); - assert_eq!(read_owner_only(&symlink), Err(SecretFileError::Unsafe)); - - let hard_link = directory.path().join("hard.jwk"); - fs::hard_link(&target, &hard_link).expect("create hard link"); - assert_eq!(read_owner_only(&hard_link), Err(SecretFileError::Unsafe)); - assert_eq!(read_owner_only(&target), Err(SecretFileError::Unsafe)); - } - - #[test] - fn directories_and_missing_paths_are_rejected() { - let directory = tempfile::tempdir().expect("temp dir"); - assert_eq!( - read_owner_only(directory.path()), - Err(SecretFileError::Unsafe) - ); - assert_eq!( - read_owner_only(&directory.path().join("absent.jwk")), - Err(SecretFileError::Unavailable) - ); - } - - #[test] - fn a_replaced_path_cannot_substitute_the_validated_file() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "original.json", 0o600); - let file = open_owner_only(&path).expect("validate the original file"); - fs::rename(&path, directory.path().join("held.json")).expect("move original"); - fs::write(&path, b"replacement-material").expect("replace the path"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("set mode"); - - assert_eq!( - &*read_validated_file(file).expect("read the validated descriptor"), - "key-material" - ); - assert_eq!(read_owner_only(&path), Err(SecretFileError::Unsafe)); - } - - #[test] - fn a_symlink_replacement_cannot_redirect_the_validated_read() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "original.json", 0o600); - let file = open_owner_only(&path).expect("validate the original file"); - fs::rename(&path, directory.path().join("held.json")).expect("move original"); - let target = directory.path().join("replacement.json"); - fs::write(&target, b"replacement-material").expect("write replacement"); - std::os::unix::fs::symlink(&target, &path).expect("replace with symlink"); - - assert_eq!( - &*read_validated_file(file).expect("read the validated descriptor"), - "key-material" - ); - assert_eq!(read_owner_only(&path), Err(SecretFileError::Unsafe)); - } - - #[test] - fn growth_after_validation_is_refused_by_the_bounded_read() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "growing.json", 0o600); - let file = open_owner_only(&path).expect("validate the short file"); - fs::write(&path, vec![b'x'; (MAX_SECRET_BYTES + 1) as usize]) - .expect("grow the opened file"); - assert_eq!(read_validated_file(file), Err(SecretFileError::TooLarge)); - } - - #[test] - fn exact_size_limit_reads_but_larger_files_are_refused() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "bounded.json", 0o400); - // Reopen for writing only while constructing the fixture. - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("set mode"); - fs::write(&path, vec![b'x'; MAX_SECRET_BYTES as usize]).expect("write exact limit"); - fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).expect("set mode"); - assert_eq!( - read_owner_only(&path).expect("exact limit reads").len() as u64, - MAX_SECRET_BYTES - ); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("set mode"); - fs::write(&path, vec![b'x'; (MAX_SECRET_BYTES + 1) as usize]).expect("write over limit"); - assert_eq!(read_owner_only(&path), Err(SecretFileError::TooLarge)); - } - - #[test] - fn invalid_utf8_is_refused_without_the_file_contents() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = write_key(directory.path(), "invalid.json", 0o600); - fs::write(&path, [0xff]).expect("write invalid UTF-8"); - assert_eq!(read_owner_only(&path), Err(SecretFileError::InvalidValue)); - } - - #[test] - fn a_fifo_is_refused_without_waiting_for_a_writer() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("fifo"); - assert!(std::process::Command::new("mkfifo") - .args(["-m", "600"]) - .arg(&path) - .status() - .expect("run mkfifo") - .success()); - assert_eq!(read_owner_only(&path), Err(SecretFileError::Unsafe)); - } -} diff --git a/crates/registry-mint/src/server.rs b/crates/registry-mint/src/server.rs deleted file mode 100644 index 13b4f2bdc0..0000000000 --- a/crates/registry-mint/src/server.rs +++ /dev/null @@ -1,1550 +0,0 @@ -//! The Mint HTTP boundary. -//! -//! The boundary serves the token endpoint, published key set, equivalent OAuth -//! authorization-server and OpenID Provider metadata resources, and two -//! liveness probes. Everything a caller sends is treated as an unauthenticated -//! claim about identity until the selected credential has been verified only -//! against that client's registered authentication material. -//! -//! The service holds two kinds of state with deliberately different lifetimes. -//! Issuer identity, signing and audit keys, listener, and token policy are startup-only: -//! changing them means restarting. The client registry is reloadable, so -//! onboarding or removing a caller never restarts a resource server. - -use std::{ - future::{Future, IntoFuture}, - io, - net::SocketAddr, - sync::{Arc, RwLock}, - time::Duration, -}; - -use axum::{ - body::{to_bytes, Body}, - extract::State, - http::{ - header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, PRAGMA}, - HeaderMap, HeaderValue, Request, StatusCode, - }, - middleware::{from_fn, Next}, - response::{IntoResponse, Response}, - routing::{get, post}, - Router, -}; -use base64::{engine::general_purpose::STANDARD, Engine as _}; -use registry_platform_httputil::MAXIMUM_TOKEN_RESPONSE_BYTES; -use serde_json::{json, Value}; -use thiserror::Error; -use tokio::net::TcpListener; -use url::{Host, Url}; -use zeroize::Zeroizing; - -use crate::{ - assertion::ClientAuthenticator, - audit::{MintAuditError, MintAuditLog}, - clients::{ClientRegistry, ClientRegistryError}, - config::{ - MintConfig, MINT_HEALTH_PATH, MINT_METADATA_PATH, MINT_OIDC_METADATA_PATH, MINT_READY_PATH, - MINT_TOKEN_PATH, - }, - error::TokenError, - replay::ReplayCache, - token::{projected_standard_token_response_bytes, MinterError, TokenMinter}, - CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, -}; - -/// Relay's verifier accepts access tokens for at most fifteen minutes. Keeping -/// this bound on the standard profile makes every accepted registration usable -/// by the resource server that profile was introduced to support, while the -/// existing Evidence profile retains Mint's wider configured range. -const MAXIMUM_STANDARD_TOKEN_LIFETIME_SECONDS: u64 = 15 * 60; - -const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; -const JSON_MEDIA_TYPE: &str = "application/json"; -const JWKS_MEDIA_TYPE: &str = "application/jwk-set+json"; -const MAXIMUM_BASIC_AUTHORIZATION_BYTES: usize = 2 * 1024; -const MAXIMUM_CLIENT_ID_BYTES: usize = 256; -const MAXIMUM_CLIENT_SECRET_BYTES: usize = 512; -const HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(5); -const MAXIMUM_HEALTH_BODY_BYTES: usize = 128; -const READY_BODY: &[u8] = br#"{"status":"ready"}"#; - -#[derive(Debug, Error)] -pub enum ServiceError { - #[error("the token signing boundary could not be initialized: {0}")] - Minter(#[from] MinterError), - #[error("the client registry could not be loaded: {0}")] - Registry(#[from] ClientRegistryError), - #[error("the audit boundary could not be initialized: {0}")] - Audit(#[from] MintAuditError), - #[error("client {0} cannot be served: {1}")] - Registration(String, &'static str), - #[error("the readiness probe failed")] - Healthcheck, -} - -/// The whole serving state: an immutable minter over a reloadable registry. -pub struct MintService { - config: MintConfig, - minter: TokenMinter, - /// Swapped wholesale on reload. Readers clone the `Arc` and release the - /// lock before any await, so a reload never blocks in-flight requests. - authenticator: RwLock>, - /// Owned by the service rather than the authenticator so that reloading the - /// registry never forgets which assertion identifiers were already spent. - replay: Arc, - audit: MintAuditLog, - metadata: Value, -} - -impl std::fmt::Debug for MintService { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("MintService") - .field("issuer", &self.config.issuer) - .field("clients", &self.client_count()) - .finish_non_exhaustive() - } -} - -impl MintService { - /// Load the keys, audit chain, and client registry described by `config`. - pub async fn load(config: MintConfig) -> Result { - let minter = TokenMinter::new(&config).await?; - let registry = Arc::new(ClientRegistry::load(&config.clients.directory)?); - check_client_profiles(®istry, &config)?; - let replay = Arc::new(ReplayCache::new( - config.client_assertion.replay_cache_entries, - )); - let authenticator = - ClientAuthenticator::new(registry, &config.client_assertion, Arc::clone(&replay)); - let audit = - MintAuditLog::initialize(&config.audit, &config.secret_providers, &config.issuer) - .await?; - let metadata = build_metadata(&config); - Ok(Self { - config, - minter, - authenticator: RwLock::new(Arc::new(authenticator)), - replay, - audit, - metadata, - }) - } - - /// Validate a configuration without taking what a serving instance holds. - /// - /// Everything [`MintService::load`] does except claiming the audit writer, - /// so an operator can check an edited configuration against the deployment - /// it is about to replace. Returns the number of registered clients. - pub async fn check(config: &MintConfig) -> Result { - let _minter = TokenMinter::new(config).await?; - let registry = ClientRegistry::load(&config.clients.directory)?; - check_client_profiles(®istry, config)?; - MintAuditLog::check(&config.audit, &config.secret_providers)?; - Ok(registry.len()) - } - - /// Re-read the client registry directory and swap it in atomically. - /// - /// A failed reload leaves the previous registry in place: a malformed file - /// dropped into the directory must not silently revoke every caller. - pub fn reload_clients(&self) -> Result { - let registry = Arc::new(ClientRegistry::load(&self.config.clients.directory)?); - // Checked on every reload, not only at startup: a registration dropped - // into the directory later must clear the same bar. - check_client_profiles(®istry, &self.config)?; - let count = registry.len(); - let authenticator = Arc::new(ClientAuthenticator::new( - registry, - &self.config.client_assertion, - Arc::clone(&self.replay), - )); - *self - .authenticator - .write() - .expect("the client registry lock is never poisoned") = authenticator; - Ok(count) - } - - #[must_use] - pub fn client_count(&self) -> usize { - self.authenticator().registry().len() - } - - #[must_use] - pub fn issuer(&self) -> &str { - &self.config.issuer - } - - #[must_use] - pub fn jwks(&self) -> &Value { - self.minter.jwks() - } - - fn authenticator(&self) -> Arc { - Arc::clone( - &self - .authenticator - .read() - .expect("the client registry lock is never poisoned"), - ) - } - - /// Authenticate a token request and mint the authority its registry entry - /// carries. Nothing is read from the assertion payload. - async fn issue( - &self, - operation: &str, - request: &TokenRequest, - now: i64, - ) -> Result { - if request.grant_type != GRANT_TYPE_CLIENT_CREDENTIALS { - return Err(TokenError::unsupported_grant_type( - "grant type is not supported", - )); - } - // Cloned out of the lock so a concurrent reload cannot block here. - let authenticator = self.authenticator(); - let authenticated = match &request.authentication { - TokenAuthentication::PrivateKeyJwt { - client_id, - client_assertion_type, - client_assertion, - } => { - if client_assertion_type != CLIENT_ASSERTION_TYPE { - return Err(TokenError::invalid_request( - "client assertion type is not supported", - )); - } - let authenticated = authenticator.authenticate(client_assertion, now).await?; - if client_id - .as_deref() - .is_some_and(|client_id| client_id != authenticated.client.client_id()) - { - return Err(TokenError::invalid_client( - "client identifier does not match the assertion", - )); - } - authenticated - } - TokenAuthentication::ClientSecret { - client_id, - client_secret, - } => authenticator.authenticate_client_secret(client_id, client_secret)?, - }; - let token = self.minter.mint(&authenticated, now).await?; - let body = serde_json::to_vec(&token) - .map_err(|_| TokenError::server_error("the token response could not be serialized"))?; - self.audit - .append_issued(operation, &authenticated, &token) - .await - .map_err(|_| TokenError::server_error("the token release could not be audited"))?; - Ok(json_response(StatusCode::OK, JSON_MEDIA_TYPE, body)) - } - - async fn reject(&self, operation: &str, error: TokenError) -> Response { - if self - .audit - .append_rejected(operation, error.code().as_str()) - .await - .is_err() - { - tracing::error!( - target: "registry_mint::audit", - operation, - "the token denial could not be audited" - ); - return TokenError::server_error("the token decision could not be audited") - .into_operation_response(operation); - } - error.into_operation_response(operation) - } - - #[must_use] - /// Report whether this loaded service can satisfy the same readiness - /// contract used by the serving endpoint. - pub async fn ready(&self) -> bool { - self.client_count() > 0 && self.minter.ready().await && self.audit.ready().await - } -} - -/// Refuse a registry whose authority profiles this configuration cannot express. -/// -/// The registry and the claim-name configuration are loaded independently, so -/// this is the only place their agreement can be established. A disagreement -/// caught here is an operator error at startup or reload. Deferring it until a -/// token request would turn the same mistake into a caller-specific outage or -/// an unusable token. -fn check_client_profiles( - registry: &ClientRegistry, - config: &MintConfig, -) -> Result<(), ServiceError> { - let claims = config.access_tokens.claims.as_ref(); - let evidence_claim_names = claims.map(|claims| { - let mut names = vec![ - claims.principal.as_str(), - claims.requester_tags.as_str(), - claims.evidence_audience.as_str(), - claims.grant_id.as_str(), - claims.grant_authority.as_str(), - ]; - names.extend(claims.actor.as_deref()); - names - }); - for client_id in registry.client_ids() { - let client = registry - .get(client_id) - .expect("client id came from this registry"); - if client.authorization().is_some() { - if config.access_tokens.audiences.len() != 1 { - return Err(ServiceError::Registration( - client_id.to_owned(), - "standard authorization requires exactly one access-token audience", - )); - } - if config.access_tokens.lifetime_seconds > MAXIMUM_STANDARD_TOKEN_LIFETIME_SECONDS { - return Err(ServiceError::Registration( - client_id.to_owned(), - "standard authorization requires an access-token lifetime of at most 900 seconds", - )); - } - let projected = - projected_standard_token_response_bytes(config, client).map_err(|_| { - ServiceError::Registration( - client_id.to_owned(), - "the standard token response could not be projected", - ) - })?; - if projected > MAXIMUM_TOKEN_RESPONSE_BYTES { - return Err(ServiceError::Registration( - client_id.to_owned(), - "standard authorization would exceed the shared client token-response bound", - )); - } - } - if let (Some(authorization), Some(evidence_claim_names)) = - (client.authorization(), evidence_claim_names.as_ref()) - { - if authorization - .claims - .keys() - .any(|name| evidence_claim_names.contains(&name.as_str())) - { - return Err(ServiceError::Registration( - client_id.to_owned(), - "a standard authorization claim would overlap configured Evidence authority", - )); - } - } - if client.authorization().is_none() && claims.is_none() { - return Err(ServiceError::Registration( - client_id.to_owned(), - "it uses Evidence authority but no Evidence claim names are configured", - )); - } - let Some(delegation) = client.delegation() else { - continue; - }; - let claims = claims.expect("an Evidence registration was checked above"); - // The claims Mint writes itself. A subject minted over one of these - // would replace authority the registry, not the caller, is supposed to - // decide. - let mut reserved = vec![ - "iss", - "aud", - "exp", - "iat", - "nbf", - "jti", - "client_id", - "sub", - "scope", - ]; - reserved.push(claims.principal.as_str()); - reserved.push(claims.requester_tags.as_str()); - reserved.push(claims.evidence_audience.as_str()); - reserved.push(claims.grant_id.as_str()); - reserved.push(claims.grant_authority.as_str()); - reserved.extend(claims.actor.as_deref()); - if claims.actor.is_none() { - return Err(ServiceError::Registration( - client_id.to_owned(), - "it declares a delegation but no actor claim name is configured", - )); - } - for path in delegation.subject_claims.values() { - let root = path.split('.').next().unwrap_or(path); - if reserved.contains(&root) { - return Err(ServiceError::Registration( - client_id.to_owned(), - "a subject claim path would overwrite an authority claim", - )); - } - } - } - Ok(()) -} - -fn build_metadata(config: &MintConfig) -> Value { - let issuer = config.issuer.trim_end_matches('/'); - let algorithms = { - let mut algorithms = config - .client_assertion - .algorithms - .iter() - .map(|algorithm| algorithm.as_header_value()) - .collect::>(); - algorithms.sort_unstable(); - algorithms.dedup(); - algorithms - }; - json!({ - "issuer": config.issuer, - "token_endpoint": format!("{issuer}{MINT_TOKEN_PATH}"), - "jwks_uri": format!("{issuer}{}", config.signing.jwks_path), - "grant_types_supported": [GRANT_TYPE_CLIENT_CREDENTIALS], - "token_endpoint_auth_methods_supported": [ - "private_key_jwt", - "client_secret_basic", - "client_secret_post" - ], - "token_endpoint_auth_signing_alg_values_supported": algorithms, - // Mint has no authorization endpoint: there is no user to redirect. - "response_types_supported": [], - }) -} - -/// One and only one authentication method selected from the token request. -enum TokenAuthentication { - PrivateKeyJwt { - client_id: Option, - client_assertion_type: String, - client_assertion: String, - }, - ClientSecret { - client_id: String, - client_secret: Zeroizing, - }, -} - -impl std::fmt::Debug for TokenAuthentication { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::PrivateKeyJwt { .. } => formatter.write_str("PrivateKeyJwt([redacted])"), - Self::ClientSecret { .. } => formatter.write_str("ClientSecret([redacted])"), - } - } -} - -/// The parameters Mint reads from a token request. -/// -/// RFC 6749 section 3.1 requires unrecognized parameters to be ignored and -/// forbids any parameter appearing more than once, so this is parsed by hand -/// rather than through a permissive form deserializer. -#[derive(Debug)] -struct TokenRequest { - grant_type: String, - authentication: TokenAuthentication, -} - -struct BasicClientCredentials { - client_id: String, - client_secret: Zeroizing, -} - -impl std::fmt::Debug for BasicClientCredentials { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("BasicClientCredentials([redacted])") - } -} - -fn parse_token_request( - body: &[u8], - basic: Option, -) -> Result { - let mut grant_type = None; - let mut client_assertion_type = None; - let mut client_assertion = None; - let mut client_id = None; - let mut client_secret = None; - - for (name, value) in url::form_urlencoded::parse(body) { - let slot = match name.as_ref() { - "grant_type" => &mut grant_type, - "client_assertion_type" => &mut client_assertion_type, - "client_assertion" => &mut client_assertion, - "client_id" => &mut client_id, - "client_secret" => &mut client_secret, - // Ignored by RFC 6749 section 3.1. - _ => continue, - }; - // A repeated parameter leaves which value was authenticated ambiguous. - if slot.is_some() { - return Err(TokenError::invalid_request( - "a request parameter was repeated", - )); - } - *slot = Some(value.into_owned()); - } - - let grant_type = - grant_type.ok_or_else(|| TokenError::invalid_request("grant_type is missing"))?; - let body_secret_present = client_secret.is_some(); - let assertion_present = client_assertion_type.is_some() || client_assertion.is_some(); - - let authentication = if let Some(basic) = basic { - if body_secret_present || assertion_present { - return Err(TokenError::invalid_request( - "multiple client authentication methods were presented", - )); - } - if let Some(client_id) = client_id { - let client_id = bounded_client_id(client_id)?; - if client_id != basic.client_id { - return Err(TokenError::invalid_client( - "client identifier does not match the Basic credential", - )); - } - } - TokenAuthentication::ClientSecret { - client_id: basic.client_id, - client_secret: basic.client_secret, - } - } else if body_secret_present { - if assertion_present { - return Err(TokenError::invalid_request( - "multiple client authentication methods were presented", - )); - } - TokenAuthentication::ClientSecret { - client_id: bounded_client_id( - client_id.ok_or_else(|| TokenError::invalid_client("client id is missing"))?, - )?, - client_secret: bounded_client_secret( - client_secret - .ok_or_else(|| TokenError::invalid_client("client secret is missing"))?, - )?, - } - } else { - TokenAuthentication::PrivateKeyJwt { - client_id: client_id.map(bounded_client_id).transpose()?, - client_assertion_type: client_assertion_type - .ok_or_else(|| TokenError::invalid_request("client_assertion_type is missing"))?, - client_assertion: client_assertion - .ok_or_else(|| TokenError::invalid_request("client_assertion is missing"))?, - } - }; - - Ok(TokenRequest { - grant_type, - authentication, - }) -} - -fn parse_basic_client_credentials( - headers: &HeaderMap, -) -> Result, TokenError> { - let mut values = headers.get_all(AUTHORIZATION).iter(); - let Some(value) = values.next() else { - return Ok(None); - }; - if values.next().is_some() { - return Err(TokenError::invalid_request( - "multiple authorization headers were presented", - )); - } - let value = value - .to_str() - .map_err(|_| TokenError::invalid_client("basic client credentials are malformed"))?; - if value.len() > MAXIMUM_BASIC_AUTHORIZATION_BYTES { - return Err(TokenError::invalid_client( - "basic client credentials are malformed", - )); - } - let Some((scheme, encoded)) = value.split_once(' ') else { - return Err(TokenError::invalid_client( - "basic client credentials are malformed", - )); - }; - let encoded = encoded.trim_start_matches(' '); - if !scheme.eq_ignore_ascii_case("Basic") - || encoded.is_empty() - || encoded.bytes().any(|byte| byte.is_ascii_whitespace()) - { - return Err(TokenError::invalid_client( - "basic client credentials are malformed", - )); - } - let decoded = Zeroizing::new( - STANDARD - .decode(encoded) - .map_err(|_| TokenError::invalid_client("basic client credentials are malformed"))?, - ); - let decoded = std::str::from_utf8(&decoded) - .map_err(|_| TokenError::invalid_client("basic client credentials are malformed"))?; - let (client_id, client_secret) = decoded - .split_once(':') - .ok_or_else(|| TokenError::invalid_client("basic client credentials are malformed"))?; - let client_id = decode_form_component(client_id)?; - let client_secret = decode_form_component(client_secret)?; - Ok(Some(BasicClientCredentials { - client_id: bounded_client_id(client_id)?, - client_secret: bounded_client_secret(client_secret)?, - })) -} - -/// Decode RFC 6749 section 2.3.1's form-encoded Basic components strictly. -fn decode_form_component(value: &str) -> Result { - let mut decoded = Vec::with_capacity(value.len()); - let bytes = value.as_bytes(); - let mut index = 0; - while index < bytes.len() { - match bytes[index] { - b'+' => decoded.push(b' '), - b'%' if index + 2 < bytes.len() => { - let high = hex_nibble(bytes[index + 1])?; - let low = hex_nibble(bytes[index + 2])?; - decoded.push((high << 4) | low); - index += 2; - } - b'%' => { - return Err(TokenError::invalid_client( - "basic client credentials are malformed", - )) - } - byte => decoded.push(byte), - } - index += 1; - } - String::from_utf8(decoded) - .map_err(|_| TokenError::invalid_client("basic client credentials are malformed")) -} - -fn hex_nibble(byte: u8) -> Result { - match byte { - b'0'..=b'9' => Ok(byte - b'0'), - b'a'..=b'f' => Ok(byte - b'a' + 10), - b'A'..=b'F' => Ok(byte - b'A' + 10), - _ => Err(TokenError::invalid_client( - "basic client credentials are malformed", - )), - } -} - -fn bounded_client_id(value: String) -> Result { - if value.trim().is_empty() || value.len() > MAXIMUM_CLIENT_ID_BYTES { - return Err(TokenError::invalid_client("client id is not bounded")); - } - Ok(value) -} - -fn bounded_client_secret(value: String) -> Result, TokenError> { - if value.is_empty() || value.len() > MAXIMUM_CLIENT_SECRET_BYTES { - return Err(TokenError::invalid_client("client secret is not bounded")); - } - Ok(Zeroizing::new(value)) -} - -/// Build the router over an already loaded service. -pub fn build_app(service: Arc) -> Router { - let jwks_path = service.config.signing.jwks_path.clone(); - let routes = Router::new() - .route(MINT_TOKEN_PATH, post(token)) - .route(&jwks_path, get(jwks)) - .route(MINT_METADATA_PATH, get(metadata)) - .route(MINT_OIDC_METADATA_PATH, get(metadata)) - .route(MINT_HEALTH_PATH, get(health)) - .route(MINT_READY_PATH, get(ready)) - .fallback(unknown_route) - .method_not_allowed_fallback(unknown_route) - .with_state(service); - routes.layer(from_fn(add_no_store)) -} - -/// Bind the configured listener and serve until `shutdown` resolves. -pub async fn serve(service: Arc, shutdown: F) -> io::Result<()> -where - F: Future + Send + 'static, -{ - let bind_ip = service - .config - .listener - .bind_address() - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let address = SocketAddr::new(bind_ip, service.config.listener.port); - let listener = TcpListener::bind(address).await?; - tracing::info!( - target: "registry_mint::server", - issuer = %service.config.issuer, - clients = service.client_count(), - "mint listening" - ); - let app = build_app(service); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .into_future() - .await -} - -/// Probe exactly Mint's private-address readiness response. -pub async fn healthcheck(raw_url: &str) -> Result<(), ServiceError> { - let url = healthcheck_url(raw_url)?; - let client = reqwest::Client::builder() - .timeout(HEALTHCHECK_TIMEOUT) - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .map_err(|_| ServiceError::Healthcheck)?; - let mut response = client - .get(url) - .send() - .await - .map_err(|_| ServiceError::Healthcheck)?; - let status = response.status(); - let content_type_is_json = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.eq_ignore_ascii_case(JSON_MEDIA_TYPE)); - let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|_| ServiceError::Healthcheck)? - { - if body.len().saturating_add(chunk.len()) > MAXIMUM_HEALTH_BODY_BYTES { - return Err(ServiceError::Healthcheck); - } - body.extend_from_slice(&chunk); - } - if !healthcheck_response_is_exact(status, content_type_is_json, &body) { - return Err(ServiceError::Healthcheck); - } - Ok(()) -} - -fn healthcheck_url(raw_url: &str) -> Result { - let url = Url::parse(raw_url).map_err(|_| ServiceError::Healthcheck)?; - let private_address = matches!( - url.host(), - Some(Host::Ipv4(address)) if address.is_loopback() || address.is_private() - ) || matches!( - url.host(), - Some(Host::Ipv6(address)) if address.is_loopback() || address.is_unique_local() - ); - if url.scheme() != "http" - || !private_address - || !url.username().is_empty() - || url.password().is_some() - || url.path() != MINT_READY_PATH - || url.query().is_some() - || url.fragment().is_some() - { - return Err(ServiceError::Healthcheck); - } - Ok(url) -} - -fn healthcheck_response_is_exact( - status: reqwest::StatusCode, - content_type_is_json: bool, - body: &[u8], -) -> bool { - status == reqwest::StatusCode::OK && content_type_is_json && body == READY_BODY -} - -async fn token(State(service): State>, request: Request) -> Response { - let operation = format!("urn:ulid:{}", ulid::Ulid::new()); - let used_http_authorization = request.headers().contains_key(AUTHORIZATION); - if !has_exact_content_type(request.headers(), FORM_MEDIA_TYPE) { - return reject_token_request( - &service, - &operation, - used_http_authorization, - TokenError::invalid_request("content type must be form encoded"), - ) - .await; - } - let basic = match parse_basic_client_credentials(request.headers()) { - Ok(basic) => basic, - Err(error) => { - return reject_token_request(&service, &operation, used_http_authorization, error).await - } - }; - - let maximum_bytes = service.config.listener.maximum_request_bytes as usize; - let timeout = Duration::from_millis(service.config.listener.request_timeout_milliseconds); - let body = - match tokio::time::timeout(timeout, to_bytes(request.into_body(), maximum_bytes)).await { - Ok(Ok(body)) => body, - Ok(Err(_)) => { - return reject_token_request( - &service, - &operation, - used_http_authorization, - TokenError::invalid_request("the request body could not be read"), - ) - .await - } - Err(_) => { - return reject_token_request( - &service, - &operation, - used_http_authorization, - TokenError::invalid_request("the request body timed out"), - ) - .await; - } - }; - - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - let parsed = match parse_token_request(&body, basic) { - Ok(parsed) => parsed, - Err(error) => { - return reject_token_request(&service, &operation, used_http_authorization, error).await - } - }; - match service.issue(&operation, &parsed, now).await { - Ok(response) => response, - Err(error) => { - reject_token_request(&service, &operation, used_http_authorization, error).await - } - } -} - -async fn reject_token_request( - service: &MintService, - operation: &str, - used_http_authorization: bool, - error: TokenError, -) -> Response { - let invalid_client = error.code() == crate::error::TokenErrorCode::InvalidClient; - let mut response = service.reject(operation, error).await; - if used_http_authorization && invalid_client && response.status() == StatusCode::UNAUTHORIZED { - response.headers_mut().insert( - axum::http::header::WWW_AUTHENTICATE, - HeaderValue::from_static("Basic realm=\"registry-mint\""), - ); - } - response -} - -async fn jwks(State(service): State>) -> Response { - match serde_json::to_vec(service.jwks()) { - Ok(body) => json_response(StatusCode::OK, JWKS_MEDIA_TYPE, body), - Err(_) => TokenError::server_error("the key set could not be serialized").into_response(), - } -} - -async fn metadata(State(service): State>) -> Response { - match serde_json::to_vec(&service.metadata) { - Ok(body) => json_response(StatusCode::OK, JSON_MEDIA_TYPE, body), - Err(_) => TokenError::server_error("the metadata could not be serialized").into_response(), - } -} - -async fn health() -> Response { - json_response( - StatusCode::OK, - JSON_MEDIA_TYPE, - br#"{"status":"ok"}"#.to_vec(), - ) -} - -async fn ready(State(service): State>) -> Response { - // A Mint with no clients or a poisoned audit writer is live but cannot - // safely issue a token, so admission fails until the process is repaired. - if !service.ready().await { - return json_response( - StatusCode::SERVICE_UNAVAILABLE, - JSON_MEDIA_TYPE, - br#"{"status":"not ready"}"#.to_vec(), - ); - } - json_response( - StatusCode::OK, - JSON_MEDIA_TYPE, - br#"{"status":"ready"}"#.to_vec(), - ) -} - -async fn unknown_route() -> Response { - TokenError::invalid_request("no such route").into_response() -} - -/// RFC 6749 section 5.1 requires both headers on token responses. Applying them -/// to every route keeps the key set and metadata out of shared caches too. -async fn add_no_store(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - let headers = response.headers_mut(); - headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - headers.insert(PRAGMA, HeaderValue::from_static("no-cache")); - headers.insert( - http::header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - response -} - -fn json_response(status: StatusCode, media_type: &'static str, body: Vec) -> Response { - ( - status, - [(CONTENT_TYPE, HeaderValue::from_static(media_type))], - body, - ) - .into_response() -} - -fn has_exact_content_type(headers: &HeaderMap, expected: &str) -> bool { - let Some(value) = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - else { - return false; - }; - // Only a bare type or one carrying the redundant charset is accepted; a - // multipart or otherwise decorated type is not this endpoint's input. - let (media_type, parameters) = match value.split_once(';') { - Some((media_type, parameters)) => (media_type, Some(parameters)), - None => (value, None), - }; - if !media_type.trim().eq_ignore_ascii_case(expected) { - return false; - } - match parameters { - None => true, - Some(parameters) => parameters.trim().eq_ignore_ascii_case("charset=utf-8"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::io::AsyncWriteExt as _; - - async fn healthcheck_against_raw_response(response: Vec) -> Result<(), ServiceError> { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); - let address = listener.local_addr().expect("listener address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("healthcheck connection"); - stream - .write_all(&response) - .await - .expect("healthcheck response"); - stream - .shutdown() - .await - .expect("finish healthcheck response"); - }); - - let result = tokio::time::timeout( - Duration::from_secs(1), - healthcheck(&format!("http://{address}{MINT_READY_PATH}")), - ) - .await - .expect("healthcheck completes after the response closes"); - server.await.expect("healthcheck server task"); - result - } - - fn raw_healthcheck_response(status: &str, headers: &str, body: &[u8]) -> Vec { - format!( - "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n{headers}\r\n", - body.len() - ) - .into_bytes() - .into_iter() - .chain(body.iter().copied()) - .collect() - } - - #[tokio::test] - async fn healthcheck_accepts_the_exact_ready_response_over_a_live_socket() { - let response = - raw_healthcheck_response("200 OK", "content-type: application/json\r\n", READY_BODY); - - assert!(healthcheck_against_raw_response(response).await.is_ok()); - } - - #[tokio::test] - async fn healthcheck_rejects_a_non_ready_response_over_a_live_socket() { - let response = raw_healthcheck_response( - "503 Service Unavailable", - "content-type: application/json\r\n", - br#"{\"status\":\"not ready\"}"#, - ); - - assert!(healthcheck_against_raw_response(response).await.is_err()); - } - - #[tokio::test] - async fn healthcheck_refuses_redirects_over_a_live_socket() { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); - let address = listener.local_addr().expect("listener address"); - let server = tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("healthcheck connection"); - let response = raw_healthcheck_response( - "302 Found", - &format!("location: http://{address}{MINT_READY_PATH}\r\n"), - &[], - ); - stream - .write_all(&response) - .await - .expect("redirect response"); - stream.shutdown().await.expect("finish redirect response"); - - assert!( - tokio::time::timeout(Duration::from_millis(100), listener.accept()) - .await - .is_err(), - "healthcheck followed the redirect" - ); - }); - - let result = tokio::time::timeout( - Duration::from_secs(1), - healthcheck(&format!("http://{address}{MINT_READY_PATH}")), - ) - .await - .expect("redirect response is bounded"); - assert!(result.is_err()); - server.await.expect("redirect server task"); - } - - #[tokio::test] - async fn healthcheck_rejects_an_oversized_response_over_a_live_socket() { - let body = vec![b'x'; MAXIMUM_HEALTH_BODY_BYTES + 1]; - let response = - raw_healthcheck_response("200 OK", "content-type: application/json\r\n", &body); - - assert!(healthcheck_against_raw_response(response).await.is_err()); - } - - #[tokio::test] - async fn healthcheck_rejects_a_truncated_response_over_a_live_socket() { - let response = b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 18\r\nconnection: close\r\n\r\n{\"status\"" - .to_vec(); - - assert!(healthcheck_against_raw_response(response).await.is_err()); - } - - #[test] - fn readiness_probe_accepts_only_private_numeric_readiness_urls() { - for accepted in [ - "http://127.0.0.1:8081/ready", - "http://10.24.0.3:8081/ready", - "http://[fd00::3]:8081/ready", - ] { - assert!(healthcheck_url(accepted).is_ok(), "rejected {accepted}"); - } - for rejected in [ - "http://example.com/ready", - "http://203.0.113.5/ready", - "https://127.0.0.1:8081/ready", - "http://127.0.0.1:8081/health", - "http://127.0.0.1:8081/ready?detail=true", - ] { - assert!(healthcheck_url(rejected).is_err(), "accepted {rejected}"); - } - } - - #[test] - fn readiness_probe_requires_the_exact_minimal_response() { - assert!(healthcheck_response_is_exact( - reqwest::StatusCode::OK, - true, - READY_BODY, - )); - assert!(!healthcheck_response_is_exact( - reqwest::StatusCode::OK, - true, - br#"{"status":"ready","issuer":"hidden"}"#, - )); - assert!(!healthcheck_response_is_exact( - reqwest::StatusCode::SERVICE_UNAVAILABLE, - true, - READY_BODY, - )); - assert!(!healthcheck_response_is_exact( - reqwest::StatusCode::OK, - false, - READY_BODY, - )); - } - - #[test] - fn a_repeated_parameter_is_rejected() { - let error = parse_token_request(b"grant_type=a&grant_type=b", None) - .expect_err("a repeated parameter must be rejected"); - assert_eq!( - error, - TokenError::invalid_request("a request parameter was repeated") - ); - } - - #[test] - fn unrecognized_parameters_are_ignored() { - let request = parse_token_request( - b"grant_type=client_credentials&scope=anything&client_assertion_type=t&client_assertion=a", - None, - ) - .expect("unrecognized parameters must be ignored"); - assert_eq!(request.grant_type, "client_credentials"); - assert!(matches!( - request.authentication, - TokenAuthentication::PrivateKeyJwt { - client_assertion_type, - client_assertion, - .. - } if client_assertion_type == "t" && client_assertion == "a" - )); - } - - #[test] - fn private_key_jwt_accepts_the_rfc7523_client_identifier() { - let request = parse_token_request( - b"grant_type=client_credentials&client_id=evidence-source&client_assertion_type=t&client_assertion=a", - None, - ) - .expect("client_id can travel beside a private-key assertion"); - assert!(matches!( - request.authentication, - TokenAuthentication::PrivateKeyJwt { - client_id: Some(client_id), - client_assertion_type, - client_assertion, - } if client_id == "evidence-source" - && client_assertion_type == "t" - && client_assertion == "a" - )); - } - - #[test] - fn each_required_parameter_is_required() { - for body in [ - &b"client_assertion_type=t&client_assertion=a"[..], - &b"grant_type=g&client_assertion=a"[..], - &b"grant_type=g&client_assertion_type=t"[..], - ] { - let error = - parse_token_request(body, None).expect_err("a missing parameter must be rejected"); - assert_eq!(error.code(), crate::error::TokenErrorCode::InvalidRequest); - } - } - - #[test] - fn client_secret_post_requires_one_bounded_pair() { - let request = parse_token_request( - b"grant_type=client_credentials&client_id=qgis-installation&client_secret=secret-value", - None, - ) - .expect("client_secret_post parses"); - assert!(matches!( - request.authentication, - TokenAuthentication::ClientSecret { client_id, client_secret } - if client_id == "qgis-installation" && client_secret.as_str() == "secret-value" - )); - - for body in [ - &b"grant_type=client_credentials&client_secret=secret-value"[..], - &b"grant_type=client_credentials&client_id=&client_secret=secret-value"[..], - &b"grant_type=client_credentials&client_id=qgis-installation&client_secret="[..], - ] { - assert_eq!( - parse_token_request(body, None) - .expect_err("an incomplete credential pair is rejected") - .code(), - crate::error::TokenErrorCode::InvalidClient - ); - } - } - - #[test] - fn client_secret_request_debug_output_is_redacted() { - let request = parse_token_request( - b"grant_type=client_credentials&client_id=qgis-installation&client_secret=secret-value", - None, - ) - .expect("client_secret_post parses"); - - let rendered = format!("{request:?}"); - assert!(rendered.contains("ClientSecret([redacted])")); - assert!(!rendered.contains("qgis-installation")); - assert!(!rendered.contains("secret-value")); - } - - #[test] - fn client_secret_basic_decodes_rfc6749_form_components() { - let credentials = STANDARD.encode("qgis%3Ainstallation:secret%3Avalue"); - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("bAsIc {credentials}")).expect("header"), - ); - let basic = parse_basic_client_credentials(&headers) - .expect("basic header parses") - .expect("basic credentials are present"); - assert_eq!(basic.client_id, "qgis:installation"); - assert_eq!(basic.client_secret.as_str(), "secret:value"); - - let request = parse_token_request(b"grant_type=client_credentials", Some(basic)) - .expect("client_secret_basic selects the credential"); - assert!(matches!( - request.authentication, - TokenAuthentication::ClientSecret { client_id, client_secret } - if client_id == "qgis:installation" && client_secret.as_str() == "secret:value" - )); - } - - #[test] - fn basic_authentication_accepts_only_a_matching_body_client_identifier() { - let basic = BasicClientCredentials { - client_id: "qgis-installation".to_owned(), - client_secret: Zeroizing::new("secret-value".to_owned()), - }; - parse_token_request( - b"grant_type=client_credentials&client_id=qgis-installation", - Some(basic), - ) - .expect("a matching informational client_id is accepted"); - - let basic = BasicClientCredentials { - client_id: "qgis-installation".to_owned(), - client_secret: Zeroizing::new("secret-value".to_owned()), - }; - assert_eq!( - parse_token_request( - b"grant_type=client_credentials&client_id=another-installation", - Some(basic), - ) - .expect_err("a mismatched client_id is rejected") - .code(), - crate::error::TokenErrorCode::InvalidClient - ); - } - - #[test] - fn malformed_or_multiple_basic_credentials_are_rejected() { - for value in ["Bearer value", "Basic", "Basic !!!", "Basic bm9jb2xvbg=="] { - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, HeaderValue::from_str(value).expect("header")); - assert_eq!( - parse_basic_client_credentials(&headers) - .expect_err("malformed Basic credentials are rejected") - .code(), - crate::error::TokenErrorCode::InvalidClient, - "{value}" - ); - } - - let mut headers = HeaderMap::new(); - headers.append(AUTHORIZATION, HeaderValue::from_static("Basic YTpi")); - headers.append(AUTHORIZATION, HeaderValue::from_static("Basic Yzpk")); - assert_eq!( - parse_basic_client_credentials(&headers) - .expect_err("multiple authorization headers are rejected") - .code(), - crate::error::TokenErrorCode::InvalidRequest - ); - } - - #[test] - fn multiple_client_authentication_methods_are_rejected() { - let basic = BasicClientCredentials { - client_id: "qgis-installation".to_owned(), - client_secret: Zeroizing::new("secret-value".to_owned()), - }; - assert_eq!( - parse_token_request( - b"grant_type=client_credentials&client_id=qgis-installation&client_secret=secret-value", - Some(basic), - ) - .expect_err("Basic and body authentication cannot be combined"), - TokenError::invalid_request("multiple client authentication methods were presented") - ); - - assert_eq!( - parse_token_request( - b"grant_type=client_credentials&client_id=qgis-installation&client_secret=secret-value&client_assertion_type=t&client_assertion=a", - None, - ) - .expect_err("secret and assertion authentication cannot be combined"), - TokenError::invalid_request("multiple client authentication methods were presented") - ); - } - - #[test] - fn content_type_must_be_the_form_media_type() { - let mut headers = HeaderMap::new(); - for (value, expected) in [ - ("application/x-www-form-urlencoded", true), - ("application/x-www-form-urlencoded; charset=utf-8", true), - ("application/x-www-form-urlencoded; charset=UTF-8", true), - ("application/json", false), - ("multipart/form-data; boundary=x", false), - ("application/x-www-form-urlencoded; boundary=x", false), - ] { - headers.insert(CONTENT_TYPE, HeaderValue::from_str(value).expect("header")); - assert_eq!( - has_exact_content_type(&headers, FORM_MEDIA_TYPE), - expected, - "{value}" - ); - } - } - - fn registry_with(extra: &str) -> ClientRegistry { - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - std::fs::write( - directory.path().join("client-a.yaml"), - format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [tag-a]\nkeys: [{public}]\n{extra}"), - ) - .expect("write client registration"); - ClientRegistry::load(directory.path()).expect("registry loads") - } - - fn claim_names() -> crate::config::ClaimNames { - crate::config::tests::sample_config() - .access_tokens - .claims - .expect("the Evidence sample names its claims") - } - - fn check_profiles( - registry: &ClientRegistry, - claims: Option<&crate::config::ClaimNames>, - audience_count: usize, - ) -> Result<(), ServiceError> { - check_profiles_with_lifetime(registry, claims, audience_count, 300) - } - - fn check_profiles_with_lifetime( - registry: &ClientRegistry, - claims: Option<&crate::config::ClaimNames>, - audience_count: usize, - lifetime_seconds: u64, - ) -> Result<(), ServiceError> { - let mut config = crate::config::tests::sample_config(); - config.access_tokens.claims = claims.cloned(); - config.access_tokens.audiences = (0..audience_count) - .map(|index| format!("audience-{index}")) - .collect(); - config.access_tokens.lifetime_seconds = lifetime_seconds; - check_client_profiles(registry, &config) - } - - const DELEGATION: &str = "delegation:\n subjectClaims:\n given_name: identity.given_name\n"; - - /// The registry and the claim-name configuration are loaded independently, - /// so a delegation with nowhere to mint its actor has to be caught here or - /// not at all. - #[test] - fn a_delegation_without_a_configured_actor_claim_refuses_to_load() { - let mut claims = claim_names(); - claims.actor = None; - let error = check_profiles(®istry_with(DELEGATION), Some(&claims), 1) - .expect_err("an unconfigured actor claim must refuse the registry"); - assert!(matches!(error, ServiceError::Registration(client, _) if client == "client-a")); - - claims.actor = Some("evidence_actor".to_owned()); - assert!(check_profiles(®istry_with(DELEGATION), Some(&claims), 1).is_ok()); - } - - /// A subject path rooted at a claim Mint writes itself would let the caller - /// choose authority the registry is supposed to decide. - #[test] - fn a_subject_path_rooted_at_an_authority_claim_refuses_to_load() { - let mut claims = claim_names(); - claims.actor = Some("evidence_actor".to_owned()); - - let mut paths = vec!["scope".to_owned(), "scope.value".to_owned()]; - paths.extend( - [ - "iss", - "sub", - "jti", - "client_id", - claims.requester_tags.as_str(), - claims.evidence_audience.as_str(), - claims.grant_id.as_str(), - claims.grant_authority.as_str(), - "evidence_actor", - ] - .into_iter() - .map(|root| format!("{root}.given_name")), - ); - for path in paths { - let registry = registry_with(&format!( - "delegation:\n subjectClaims:\n given_name: {path}\n" - )); - assert!( - matches!( - check_profiles(®istry, Some(&claims), 1), - Err(ServiceError::Registration(client, reason)) - if client == "client-a" - && reason == "a subject claim path would overwrite an authority claim" - ), - "a subject path at {path} must be refused as an authority collision" - ); - } - } - - /// A registry with no delegations is unaffected by the actor claim either - /// way, so an existing deployment does not have to configure one. - #[test] - fn an_undelegated_registry_loads_without_an_actor_claim() { - let mut claims = claim_names(); - claims.actor = None; - assert!(check_profiles(®istry_with(""), Some(&claims), 1).is_ok()); - } - - #[test] - fn evidence_registrations_require_claim_names_but_scoped_registrations_do_not() { - assert!(matches!( - check_profiles(®istry_with(""), None, 1), - Err(ServiceError::Registration(client, _)) if client == "client-a" - )); - - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - std::fs::write( - directory.path().join("client-a.yaml"), - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization: {{scopes: [registry:read]}}\nkeys: [{public}]\n" - ), - ) - .expect("write scoped registration"); - let registry = ClientRegistry::load(directory.path()).expect("registry loads"); - assert!(check_profiles(®istry, None, 1).is_ok()); - } - - #[test] - fn standard_authorization_cannot_overlap_configured_evidence_authority() { - let mut claims = claim_names(); - claims.principal = "evidence_principal".to_owned(); - claims.actor = Some("evidence_actor".to_owned()); - for name in [ - claims.principal.as_str(), - claims.requester_tags.as_str(), - claims.evidence_audience.as_str(), - claims.grant_id.as_str(), - claims.grant_authority.as_str(), - claims - .actor - .as_deref() - .expect("the actor claim is configured"), - ] { - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - std::fs::write( - directory.path().join("client-a.yaml"), - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization:\n scopes: [registry:read]\n claims: {{{name}: authority}}\nkeys: [{public}]\n" - ), - ) - .expect("write scoped registration"); - let registry = ClientRegistry::load(directory.path()).expect("registry loads"); - assert!(matches!( - check_profiles(®istry, Some(&claims), 1), - Err(ServiceError::Registration(client, _)) if client == "client-a" - )); - } - } - - #[test] - fn standard_authorization_requires_one_exact_audience() { - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - std::fs::write( - directory.path().join("client-a.yaml"), - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization: {{scopes: [registry:read]}}\nkeys: [{public}]\n" - ), - ) - .expect("write scoped registration"); - let registry = ClientRegistry::load(directory.path()).expect("registry loads"); - - assert!(check_profiles(®istry, None, 1).is_ok()); - for audience_count in [0, 2] { - assert!(matches!( - check_profiles(®istry, None, audience_count), - Err(ServiceError::Registration(client, _)) if client == "client-a" - )); - } - } - - #[test] - fn standard_authorization_requires_a_relay_compatible_lifetime() { - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - std::fs::write( - directory.path().join("client-a.yaml"), - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization: {{scopes: [registry:read]}}\nkeys: [{public}]\n" - ), - ) - .expect("write scoped registration"); - let registry = ClientRegistry::load(directory.path()).expect("registry loads"); - - assert!(check_profiles_with_lifetime(®istry, None, 1, 900).is_ok()); - assert!(matches!( - check_profiles_with_lifetime(®istry, None, 1, 901), - Err(ServiceError::Registration(client, _)) if client == "client-a" - )); - } - - #[test] - fn standard_authorization_must_fit_the_shared_token_response_bound() { - let directory = tempfile::tempdir().expect("temp dir"); - let public = crate::assertion::tests::test_key(1).1; - let claims = (0..32) - .map(|index| format!(" claim{index}: '{}'", "x".repeat(512))) - .collect::>() - .join("\n"); - std::fs::write( - directory.path().join("client-a.yaml"), - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization:\n scopes: [registry:read]\n claims:\n{claims}\nkeys: [{public}]\n" - ), - ) - .expect("write oversized scoped registration"); - let registry = ClientRegistry::load(directory.path()) - .expect("per-field-valid standard authority loads"); - - assert!(matches!( - check_profiles(®istry, None, 1), - Err(ServiceError::Registration(client, reason)) - if client == "client-a" && reason.contains("token-response bound") - )); - } - - #[test] - fn metadata_describes_the_endpoints_a_client_needs() { - let config = crate::config::tests::sample_config(); - let metadata = build_metadata(&config); - assert_eq!(metadata["issuer"], json!("https://mint.example.org")); - assert_eq!( - metadata["token_endpoint"], - json!("https://mint.example.org/token") - ); - assert_eq!( - metadata["jwks_uri"], - json!("https://mint.example.org/.well-known/jwks.json") - ); - assert_eq!( - metadata["token_endpoint_auth_methods_supported"], - json!([ - "private_key_jwt", - "client_secret_basic", - "client_secret_post" - ]) - ); - assert_eq!( - metadata["grant_types_supported"], - json!(["client_credentials"]) - ); - } -} diff --git a/crates/registry-mint/src/token.rs b/crates/registry-mint/src/token.rs deleted file mode 100644 index bda037d7f7..0000000000 --- a/crates/registry-mint/src/token.rs +++ /dev/null @@ -1,1368 +0,0 @@ -//! Access token minting. -//! -//! Every authority claim written here is read from the server-side client -//! registry. That is what makes a caller's private key a proof of *identity* -//! rather than a licence to assert whatever it likes: the caller chooses which -//! registry entry it authenticates as, and the registry chooses what that entry -//! may say. -//! -//! A delegated token is the one case where values reach a token from the -//! caller, and they arrive already reconciled against the registration by -//! [`crate::assertion`]: the actor is one the client may act as, and the -//! subject holds exactly the selector fields the registration declared, minted -//! at exactly the claim paths it declared. The registry still fixes the shape; -//! the caller only fills it in. -//! -//! Minting the subject into the token is what bounds a delegated token to one -//! person. A resource server configured to read that subject from the token -//! refuses any request carrying its own selector values, so a token issued for -//! one subject cannot be turned toward another, however the caller misbehaves. - -use std::{collections::BTreeSet, path::Path, sync::Arc, time::Duration}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_config::{SecretError, SecretProvider, SecretResolver}; -use registry_platform_crypto::{ - verify, KeyReadiness, LocalJwkSigner, PrivateJwk, PublicJwk, SigningAlgorithm, SigningError, - SigningProvider, TransitSigner, TransitSignerConfig, -}; -use serde::Serialize; -use serde_json::{json, Map, Value}; -use thiserror::Error; - -use crate::{ - assertion::AuthenticatedClient, - clients::{Delegation, RegisteredClient}, - config::{ClaimNames, MintConfig, SignerConfig}, - error::TokenError, - ACCESS_TOKEN_TYP, -}; - -#[derive(Debug, Error)] -pub enum MinterError { - #[error("the signing secret could not be resolved")] - SigningSecret(#[source] SecretError), - #[error("the signing key is invalid: {0}")] - SigningKey(&'static str), - #[error("a governed public key is invalid: {0}")] - PublicKey(&'static str), - #[error("the signing provider configuration is invalid: {0}")] - SigningProviderConfiguration(#[source] SigningError), - #[error("the signing provider initialization failed: {0}")] - SigningProviderInitialization(#[source] SigningError), - #[error("the signing provider self-test failed: {0}")] - SigningProviderSelfTest(#[source] SigningError), -} - -/// A minted access token and the lifetime the caller should assume. -#[derive(Debug, Serialize)] -pub struct MintedToken { - pub access_token: String, - pub token_type: &'static str, - pub expires_in: u64, - /// The exact standard OAuth scope Mint granted. Evidence-profile tokens do - /// not carry scopes and therefore omit this response member. - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option, - #[serde(skip)] - token_id: String, - #[serde(skip)] - signing_key_id: String, - #[serde(skip)] - expires_at_unix: i64, -} - -impl MintedToken { - #[must_use] - pub(crate) fn token_id(&self) -> &str { - &self.token_id - } - - #[must_use] - pub(crate) fn signing_key_id(&self) -> &str { - &self.signing_key_id - } - - #[must_use] - pub(crate) fn expires_at_unix(&self) -> i64 { - self.expires_at_unix - } -} - -/// Signs access tokens with the configured active key. -pub struct TokenMinter { - issuer: String, - audience: Value, - lifetime_seconds: i64, - claims: Option, - signer: Arc, - governed_active: PublicJwk, - recovery_probe: tokio::sync::Mutex<()>, - jwks: Value, -} - -impl std::fmt::Debug for TokenMinter { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("TokenMinter") - .field("issuer", &self.issuer) - .field("algorithm", &self.signer.algorithm()) - .field("key_id", &self.signer.key_id()) - .finish_non_exhaustive() - } -} - -impl TokenMinter { - /// Load the active signing key and build the published JWK set. - pub async fn new(config: &MintConfig) -> Result { - let public_keys = load_public_keys(config)?; - let active = public_keys - .first() - .cloned() - .expect("the active governed key is always present"); - let signer = build_signer(config, &active).await?; - self_test(signer.as_ref(), &active).await?; - let jwks = json!({ "keys": public_keys }); - - let audience = configured_audience(&config.access_tokens.audiences); - - Ok(Self { - issuer: config.issuer.clone(), - audience, - lifetime_seconds: config.access_tokens.lifetime_seconds as i64, - claims: config.access_tokens.claims.clone(), - signer, - governed_active: active, - recovery_probe: tokio::sync::Mutex::new(()), - jwks, - }) - } - - /// The public key set resource servers fetch to verify minted tokens. - #[must_use] - pub fn jwks(&self) -> &Value { - &self.jwks - } - - #[must_use] - pub fn issuer(&self) -> &str { - &self.issuer - } - - /// Current availability of the active signing provider. - /// - /// Transit marks itself unavailable after a failed request. When no token - /// traffic reaches an unready replica, the readiness route is the only - /// remaining path that can observe provider recovery. One caller therefore - /// repeats the bounded startup sign-and-verify proof while concurrent - /// probes fail closed rather than queueing more provider work. - pub async fn ready(&self) -> bool { - if self.signer.readiness() == KeyReadiness::Ready { - return true; - } - let Ok(_probe) = self.recovery_probe.try_lock() else { - return false; - }; - if self.signer.readiness() == KeyReadiness::Ready { - return true; - } - self_test(self.signer.as_ref(), &self.governed_active) - .await - .is_ok() - } - - /// Mint an access token carrying the registry's authority for `client`. - pub async fn mint( - &self, - authenticated: &AuthenticatedClient, - now: i64, - ) -> Result { - let client: &RegisteredClient = &authenticated.client; - let expires_at = now + self.lifetime_seconds; - let token_id = ulid::Ulid::new().to_string(); - let (mut claims, scope) = registered_claims( - &self.issuer, - &self.audience, - self.claims.as_ref(), - client, - now, - expires_at, - &token_id, - )?; - - if let Some(delegation) = &authenticated.delegation { - let registered = client.delegation().ok_or_else(|| { - TokenError::server_error("a delegation was resolved for an undelegated client") - })?; - // Startup refuses a registry that declares delegation without a - // configured actor claim, so reaching here means the two disagree. - let actor_claim = self - .claims - .as_ref() - .and_then(|claims| claims.actor.as_ref()) - .ok_or_else(|| { - TokenError::server_error("no actor claim is configured for delegated tokens") - })?; - claims.insert( - actor_claim.clone(), - Value::String(delegation.actor().to_owned()), - ); - write_subject_claims(&mut claims, registered, delegation)?; - } - - let signing_input = signing_input(self.signer.key_id(), claims)?; - let signature = self - .signer - .sign(signing_input.as_bytes()) - .await - .map_err(|_| TokenError::server_error("the access token could not be signed"))?; - - Ok(MintedToken { - access_token: format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)), - token_type: "Bearer", - expires_in: self.lifetime_seconds as u64, - scope, - token_id, - signing_key_id: self.signer.key_id().to_owned(), - expires_at_unix: expires_at, - }) - } -} - -/// Project the largest token response this standard registration can produce. -/// -/// The shared client reads at most 16 KiB. Startup and reload use this exact -/// serialization path with maximum-width timestamps and the fixed ES256 -/// signature width so Mint cannot accept authority that its paired client must -/// reject after issuance. -pub(crate) fn projected_standard_token_response_bytes( - config: &MintConfig, - client: &RegisteredClient, -) -> Result { - if client.authorization().is_none() { - return Err(TokenError::server_error( - "a standard token response was projected for an Evidence registration", - )); - } - let lifetime = i64::try_from(config.access_tokens.lifetime_seconds) - .map_err(|_| TokenError::server_error("the access token lifetime is invalid"))?; - let expires_at = i64::MAX; - let now = expires_at - lifetime; - let token_id = "Z".repeat(26); - let key_id = "Z".repeat(43); - let (claims, scope) = registered_claims( - &config.issuer, - &configured_audience(&config.access_tokens.audiences), - config.access_tokens.claims.as_ref(), - client, - now, - expires_at, - &token_id, - )?; - let signing_input = signing_input(&key_id, claims)?; - let response = MintedToken { - access_token: format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode([0_u8; 64])), - token_type: "Bearer", - expires_in: config.access_tokens.lifetime_seconds, - scope, - token_id, - signing_key_id: key_id, - expires_at_unix: expires_at, - }; - let bytes = serde_json::to_vec(&response) - .map_err(|_| TokenError::server_error("the token response could not be projected"))? - .len(); - u64::try_from(bytes) - .map_err(|_| TokenError::server_error("the token response size could not be represented")) -} - -fn configured_audience(audiences: &[String]) -> Value { - if audiences.len() == 1 { - Value::String(audiences[0].clone()) - } else { - Value::Array( - audiences - .iter() - .map(|audience| Value::String(audience.clone())) - .collect(), - ) - } -} - -fn registered_claims( - issuer: &str, - audience: &Value, - evidence_names: Option<&ClaimNames>, - client: &RegisteredClient, - now: i64, - expires_at: i64, - token_id: &str, -) -> Result<(Map, Option), TokenError> { - let mut claims = Map::new(); - claims.insert("iss".to_owned(), Value::String(issuer.to_owned())); - claims.insert("aud".to_owned(), audience.clone()); - claims.insert("iat".to_owned(), json!(now)); - claims.insert("nbf".to_owned(), json!(now)); - claims.insert("exp".to_owned(), json!(expires_at)); - claims.insert("jti".to_owned(), Value::String(token_id.to_owned())); - // `client_id` records which registration authenticated; the principal is - // what the resource server acts on. They are allowed to differ. - claims.insert( - "client_id".to_owned(), - Value::String(client.client_id().to_owned()), - ); - // `sub` always carries the principal so the token is meaningful to a - // standard OAuth consumer, even when the resource server reads the - // principal from a differently named claim. - claims.insert( - "sub".to_owned(), - Value::String(client.principal().to_owned()), - ); - - let scope = if let Some(authorization) = client.authorization() { - let scope = authorization.scopes.join(" "); - claims.insert("scope".to_owned(), Value::String(scope.clone())); - for (name, value) in &authorization.claims { - claims.insert(name.clone(), value.to_json()); - } - Some(scope) - } else { - let names = evidence_names - .ok_or_else(|| TokenError::server_error("Evidence claim names are not configured"))?; - let requester_tags = client.requester_tags().ok_or_else(|| { - TokenError::server_error("an Evidence registration has no requester tags") - })?; - let evidence_audience = client.evidence_audience().ok_or_else(|| { - TokenError::server_error("an Evidence registration has no evidence audience") - })?; - claims.insert( - names.principal.clone(), - Value::String(client.principal().to_owned()), - ); - claims.insert( - names.requester_tags.clone(), - Value::Array( - requester_tags - .iter() - .map(|tag| Value::String(tag.clone())) - .collect(), - ), - ); - claims.insert( - names.evidence_audience.clone(), - Value::String(evidence_audience.to_owned()), - ); - // Evidence requires the grant id and authority together or not at all, - // which the registry already guarantees by construction. - if let Some(grant) = client.grant() { - claims.insert(names.grant_id.clone(), Value::String(grant.id.clone())); - claims.insert( - names.grant_authority.clone(), - Value::String(grant.authority.clone()), - ); - } - None - }; - Ok((claims, scope)) -} - -fn signing_input(key_id: &str, claims: Map) -> Result { - let header = json!({ - "alg": "ES256", - "typ": ACCESS_TOKEN_TYP, - "kid": key_id, - }); - Ok(format!( - "{}.{}", - encode_json(&header)?, - encode_json(&Value::Object(claims))? - )) -} - -/// Write each subject selector value at the claim path its registration -/// declared, creating the intermediate objects the path implies. -/// -/// The registration's paths were checked at load time to be well formed, -/// unique, and non-nesting, so no write here can overwrite another. Anything -/// that would still collide is a bug rather than a caller's doing, and is -/// refused rather than allowed to overwrite an authority claim. -fn write_subject_claims( - claims: &mut Map, - registered: &Delegation, - delegation: &crate::assertion::ResolvedDelegation, -) -> Result<(), TokenError> { - let collision = - || TokenError::server_error("a subject claim path collides with an authority claim"); - - for (field, path) in ®istered.subject_claims { - let value = delegation - .subject() - .get(field) - .ok_or_else(|| TokenError::server_error("a resolved subject field is missing"))?; - - let mut segments = path.split('.').peekable(); - let mut current = &mut *claims; - while let Some(segment) = segments.next() { - if segments.peek().is_none() { - if current.contains_key(segment) { - return Err(collision()); - } - current.insert(segment.to_owned(), value.clone()); - break; - } - let entry = current - .entry(segment.to_owned()) - .or_insert_with(|| Value::Object(Map::new())); - current = entry.as_object_mut().ok_or_else(collision)?; - } - } - Ok(()) -} - -fn encode_json(value: &Value) -> Result { - let bytes = serde_json::to_vec(value) - .map_err(|_| TokenError::server_error("a token component could not be serialized"))?; - Ok(URL_SAFE_NO_PAD.encode(bytes)) -} - -async fn build_signer( - config: &MintConfig, - active: &PublicJwk, -) -> Result, MinterError> { - match &config.signer { - SignerConfig::LocalJwk { private_key_ref } => { - let resolver = SecretResolver::new( - [SecretProvider::File], - config.secret_providers.file.root.clone(), - ) - .map_err(MinterError::SigningSecret)?; - let secret = resolver - .resolve(private_key_ref) - .map_err(MinterError::SigningSecret)?; - let text = std::str::from_utf8(secret.expose_secret()) - .map_err(|_| MinterError::SigningKey("private JWK is not UTF-8"))?; - let private = PrivateJwk::parse(text) - .map_err(|_| MinterError::SigningKey("not an exact ES256 private JWK"))?; - let signer = LocalJwkSigner::new(private) - .map_err(|_| MinterError::SigningKey("private JWK is not usable"))?; - if signer.algorithm() != SigningAlgorithm::Es256 || signer.public_jwk() != *active { - return Err(MinterError::SigningKey( - "private JWK does not match the governed active public JWK", - )); - } - Ok(Arc::new(signer)) - } - SignerConfig::Transit { - unix_socket_path, - mount, - key_name, - key_version, - timeout_milliseconds, - } => { - let transit = TransitSignerConfig::new( - unix_socket_path, - mount, - key_name, - *key_version, - active.clone(), - Duration::from_millis(*timeout_milliseconds), - ) - .map_err(MinterError::SigningProviderConfiguration)?; - let signer = TransitSigner::initialize(transit) - .await - .map_err(MinterError::SigningProviderInitialization)?; - Ok(Arc::new(signer)) - } - } -} - -async fn self_test(signer: &dyn SigningProvider, expected: &PublicJwk) -> Result<(), MinterError> { - if signer.algorithm() != SigningAlgorithm::Es256 - || signer.key_id() != expected.kid.as_deref().unwrap_or_default() - || signer.public_jwk() != *expected - { - return Err(MinterError::SigningKey( - "provider metadata does not match the governed active public JWK", - )); - } - let probe = b"registry-mint/signing-provider-self-test/v1"; - let signature = signer - .sign(probe) - .await - .map_err(MinterError::SigningProviderSelfTest)?; - if signature.len() != 64 || verify(probe, &signature, expected).is_err() { - return Err(MinterError::SigningKey( - "provider self-test signature did not verify", - )); - } - Ok(()) -} - -fn load_public_keys(config: &MintConfig) -> Result, MinterError> { - let revoked = config - .signing - .revoked_key_ids - .iter() - .map(String::as_str) - .collect::>(); - let paths = std::iter::once(&config.signing.active_public_jwk_file) - .chain(config.signing.published_public_jwk_files.iter()); - let mut keys = Vec::with_capacity(1 + config.signing.published_public_jwk_files.len()); - let mut identifiers = BTreeSet::new(); - for path in paths { - let key = load_public_key(path)?; - let kid = key - .kid - .as_deref() - .ok_or(MinterError::PublicKey("key id is missing"))?; - let expected_file_name = format!("{kid}.jwk.json"); - if path.file_name().and_then(|name| name.to_str()) != Some(expected_file_name.as_str()) { - return Err(MinterError::PublicKey( - "file name must be .jwk.json", - )); - } - if revoked.contains(kid) { - return Err(MinterError::PublicKey("a published key is revoked")); - } - if !identifiers.insert(kid.to_owned()) { - return Err(MinterError::PublicKey( - "key id is repeated in the published set", - )); - } - keys.push(key); - } - Ok(keys) -} - -fn load_public_key(path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path) - .map_err(|_| MinterError::PublicKey("file is unreadable"))?; - if !metadata.is_file() { - return Err(MinterError::PublicKey("path is not a regular file")); - } - let bytes = std::fs::read(path).map_err(|_| MinterError::PublicKey("file is unreadable"))?; - if bytes.len() > registry_platform_crypto::MAX_JWK_JSON_BYTES { - return Err(MinterError::PublicKey("document is too large")); - } - let text = - std::str::from_utf8(&bytes).map_err(|_| MinterError::PublicKey("document is not UTF-8"))?; - let value: Value = registry_platform_crypto::parse_json_strict(&bytes) - .map_err(|_| MinterError::PublicKey("document is not strict JSON"))?; - let object = value - .as_object() - .ok_or(MinterError::PublicKey("document is not a JSON object"))?; - let fields = object.keys().map(String::as_str).collect::>(); - let required = ["alg", "crv", "kid", "kty", "x", "y"] - .into_iter() - .collect::>(); - if fields != required { - return Err(MinterError::PublicKey( - "must contain exactly kty, crv, x, y, alg, and kid", - )); - } - let key = - PublicJwk::parse(text).map_err(|_| MinterError::PublicKey("is not a usable public JWK"))?; - if key.algorithm().ok() != Some(SigningAlgorithm::Es256) - || key.kty != "EC" - || key.crv.as_deref() != Some("P-256") - || key.alg.as_deref() != Some("ES256") - { - return Err(MinterError::PublicKey("must be an ES256 P-256 JWK")); - } - let thumbprint = key - .jkt() - .map_err(|_| MinterError::PublicKey("thumbprint could not be derived"))?; - if key.kid.as_deref() != Some(thumbprint.as_str()) || thumbprint.len() != 43 { - return Err(MinterError::PublicKey( - "kid must be the RFC 7638 thumbprint", - )); - } - Ok(key) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::clients::ClientRegistry; - use p256::ecdsa::SigningKey as P256SigningKey; - use std::{ - fs, - os::unix::fs::PermissionsExt, - sync::atomic::{AtomicBool, AtomicUsize, Ordering}, - }; - - const NOW: i64 = 1_800_000_000; - - fn client_key(seed: u8, kid: &str) -> (String, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let d = URL_SAFE_NO_PAD.encode(seed_bytes); - let private = - json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); - let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); - (private.to_string(), public) - } - - fn p256_key(seed: u8) -> (String, Value) { - let scalar = [seed; 32]; - let signing = P256SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); - let encoded = signing.verifying_key().to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); - let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); - let d = URL_SAFE_NO_PAD.encode(scalar); - let public_without_kid = PublicJwk::parse( - &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), - ) - .expect("public P-256 JWK parses"); - let kid = public_without_kid.jkt().expect("thumbprint computes"); - let private = json!({ - "kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, - "x":x, "y":y, "d":d - }); - let public = json!({ - "kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, - "x":x, "y":y - }); - (private.to_string(), public) - } - - struct Fixture { - _directory: tempfile::TempDir, - minter: TokenMinter, - registry: ClientRegistry, - } - - async fn fixture(grant: Option<&str>) -> Fixture { - build_fixture(grant, "", "").await - } - - /// `registration` appends lines to the client registration, `claim` appends - /// lines to the configured claim names. Both are how the delegation tests - /// reach a shape the plain fixture does not have. - async fn build_fixture(grant: Option<&str>, registration: &str, claim: &str) -> Fixture { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path(); - fs::create_dir_all(root.join("clients")).expect("client dir"); - fs::create_dir_all(root.join("public-keys")).expect("public key dir"); - fs::create_dir_all(root.join("secrets")).expect("secret dir"); - - let (private, public) = p256_key(9); - let key_path = root.join("secrets/signing.jwk"); - fs::write(&key_path, private).expect("write signing key"); - fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).expect("chmod"); - let public_file = format!( - "{}.jwk.json", - public["kid"].as_str().expect("service key has kid") - ); - fs::write( - root.join("public-keys").join(&public_file), - public.to_string(), - ) - .expect("write public key"); - - let grant_line = grant - .map(|value| format!("grant: {value}\n")) - .unwrap_or_default(); - fs::write( - root.join("clients/client-a.yaml"), - format!("clientId: client-a\nprincipal: urn:example:client-a\nevidenceAudience: https://client-a.example.org\nrequesterTags: [ministry-of-health, tier-one]\n{grant_line}keys: [{}]\n{registration}", client_key(1, "client-a-1").1), - ) - .expect("write client"); - - let config_path = root.join("mint.yaml"); - let mut document = String::from( - r#" -version: 1 -validationMode: supervised-local-development -issuer: http://127.0.0.1:8081 -listener: {address: 127.0.0.1, port: 8081} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/PUBLIC - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {root: ROOT} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -"#, - ); - document.push_str(claim); - document.push_str( - r#"clientAssertion: - audience: http://127.0.0.1:8081/token - algorithms: [EdDSA] -clients: - directory: clients -"#, - ); - document = document - .replace("ROOT", &root.join("secrets").display().to_string()) - .replace("PUBLIC", &public_file); - fs::write(&config_path, document).expect("write config"); - - let config = MintConfig::load(&config_path).expect("config loads"); - let registry = ClientRegistry::load(&config.clients.directory).expect("registry loads"); - let minter = TokenMinter::new(&config).await.expect("minter builds"); - Fixture { - _directory: directory, - minter, - registry, - } - } - - fn write_public_key(directory: &Path, seed: u8) -> std::path::PathBuf { - let (_private, public) = p256_key(seed); - let kid = public["kid"].as_str().expect("key has kid"); - let path = directory.join(format!("{kid}.jwk.json")); - fs::write(&path, public.to_string()).expect("write public key"); - path - } - - #[test] - fn published_keys_load_beside_the_active_key() { - let directory = tempfile::tempdir().expect("temp dir"); - let active = write_public_key(directory.path(), 9); - let published = write_public_key(directory.path(), 4); - let mut config = crate::config::tests::sample_config(); - config.signing.active_public_jwk_file = active; - config.signing.published_public_jwk_files = vec![published]; - - let keys = load_public_keys(&config).expect("governed set loads"); - assert_eq!(keys.len(), 2); - assert_ne!(keys[0].kid, keys[1].kid); - } - - #[test] - fn a_public_key_may_not_repeat_the_active_key_id() { - let directory = tempfile::tempdir().expect("temp dir"); - let active = write_public_key(directory.path(), 9); - let mut config = crate::config::tests::sample_config(); - config.signing.active_public_jwk_file = active.clone(); - config.signing.published_public_jwk_files = vec![active]; - - let error = load_public_keys(&config).expect_err("duplicate id is rejected"); - assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); - } - - /// Consumers parse the whole set into `JwkSet` before selecting a key, so a - /// retired entry that is well-formed JSON but not a usable public JWK takes - /// the whole set down with it: JWKS refresh fails and tokens signed by the - /// active key start being rejected, while Mint goes on reporting itself - /// ready. Checking for a `kid` string is not the same as checking the entry - /// is a key. - #[test] - fn an_entry_that_is_not_an_exact_es256_public_key_is_refused() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("retired.jwk"); - fs::write(&path, r#"{"kid":"mint-2025-07"}"#).expect("write retired key"); - - let error = load_public_key(&path).expect_err("an unusable entry is rejected"); - - assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); - } - - /// RFC 7518 section 6.3.2.7 puts the remaining prime factors of a - /// multi-prime RSA private key in `oth`. A real private key carries `d` too - /// and is caught by that, but the published set must not depend on which - /// private member happens to be present. - #[test] - fn a_public_entry_carrying_private_material_is_refused() { - let directory = tempfile::tempdir().expect("temp dir"); - let path = directory.path().join("retired.jwk"); - fs::write( - &path, - r#"{"kty":"RSA","kid":"mint-2025-07","n":"sXchDaQ","e":"AQAB","oth":[{"r":"sXchDaQ","d":"sXchDaQ","t":"sXchDaQ"}]}"#, - ) - .expect("write retired key"); - - let error = load_public_key(&path).expect_err("private material is rejected"); - - assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); - } - - #[test] - fn revoked_keys_cannot_be_active_or_published() { - let directory = tempfile::tempdir().expect("temp dir"); - let active = write_public_key(directory.path(), 9); - let active_key = load_public_key(&active).expect("key loads"); - let mut config = crate::config::tests::sample_config(); - config.signing.active_public_jwk_file = active; - config.signing.revoked_key_ids = vec![active_key.kid.expect("kid")]; - - let error = load_public_keys(&config).expect_err("revoked active key is rejected"); - assert!(matches!(error, MinterError::PublicKey(_)), "{error:?}"); - } - - #[test] - fn governed_key_ids_and_file_names_are_derived_not_chosen() { - let directory = tempfile::tempdir().expect("temp dir"); - let (_private, mut public) = p256_key(9); - public["kid"] = json!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); - let chosen_path = directory - .path() - .join("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.jwk.json"); - fs::write(&chosen_path, public.to_string()).expect("write chosen-id key"); - assert!(load_public_key(&chosen_path).is_err()); - - let valid_path = write_public_key(directory.path(), 8); - let wrong_name = directory.path().join("active.jwk.json"); - fs::rename(&valid_path, &wrong_name).expect("rename valid key"); - let mut config = crate::config::tests::sample_config(); - config.signing.active_public_jwk_file = wrong_name; - assert!(load_public_keys(&config).is_err()); - } - - #[tokio::test] - async fn local_private_material_must_match_the_governed_active_key() { - let directory = tempfile::tempdir().expect("temp dir"); - let (private, _) = p256_key(8); - let secret_path = directory.path().join("mint-signing"); - fs::write(&secret_path, private).expect("write private key"); - fs::set_permissions(&secret_path, fs::Permissions::from_mode(0o600)).expect("chmod"); - let (_, governed_value) = p256_key(9); - let governed = PublicJwk::parse(&governed_value.to_string()).expect("public JWK parses"); - let mut config = crate::config::tests::sample_config(); - config.validation_mode = crate::config::ValidationMode::SupervisedLocalDevelopment; - config.signer = SignerConfig::LocalJwk { - private_key_ref: "secret:file/mint-signing".to_owned(), - }; - config.secret_providers.file.root = directory.path().to_path_buf(); - - assert!( - build_signer(&config, &governed).await.is_err(), - "a private key for another public JWK must be rejected" - ); - } - - fn undelegated(client: &std::sync::Arc) -> AuthenticatedClient { - AuthenticatedClient { - client: std::sync::Arc::clone(client), - delegation: None, - } - } - - fn decode_claims(token: &str) -> Value { - let segment = token.split('.').nth(1).expect("token has a claims segment"); - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("claims decode")) - .expect("claims parse") - } - - fn decode_header(token: &str) -> Value { - let segment = token.split('.').next().expect("token has a header segment"); - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segment).expect("header decode")) - .expect("header parse") - } - - #[tokio::test] - async fn minted_claims_come_from_the_registry() { - let fixture = fixture(None).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - - let claims = decode_claims(&minted.access_token); - assert_eq!(claims["iss"], json!("http://127.0.0.1:8081")); - assert_eq!(claims["aud"], json!("evidence")); - assert_eq!(claims["sub"], json!("urn:example:client-a")); - assert_eq!(claims["client_id"], json!("client-a")); - assert_eq!( - claims["evidence_tags"], - json!(["ministry-of-health", "tier-one"]) - ); - assert_eq!( - claims["evidence_audience"], - json!("https://client-a.example.org") - ); - assert_eq!(claims["iat"], json!(NOW)); - assert_eq!(claims["nbf"], json!(NOW)); - assert_eq!(claims["exp"], json!(NOW + 300)); - assert_eq!(minted.expires_in, 300); - assert_eq!(minted.token_type, "Bearer"); - assert_eq!(minted.scope, None); - assert!( - serde_json::to_value(&minted) - .expect("token response serializes") - .get("scope") - .is_none(), - "Evidence token responses retain their existing shape" - ); - } - - #[tokio::test] - async fn scoped_authority_is_minted_from_the_registration_as_standard_claims() { - let fixture = fixture(None).await; - let client_path = fixture._directory.path().join("clients/client-a.yaml"); - fs::write( - &client_path, - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization:\n scopes: [registry:business:read, registry:business:lookup]\n claims:\n purpose: statutory-consultation\n authority: district-17\nkeys: [{}]\n", - client_key(1, "client-a-1").1 - ), - ) - .expect("write scoped client"); - let registry = ClientRegistry::load( - client_path - .parent() - .expect("the registration has a parent directory"), - ) - .expect("scoped registry loads"); - let client = registry.get("client-a").expect("client registered"); - - let minted = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - let claims = decode_claims(&minted.access_token); - - assert_eq!( - claims["scope"], - json!("registry:business:read registry:business:lookup") - ); - assert_eq!(claims["purpose"], json!("statutory-consultation")); - assert_eq!(claims["authority"], json!("district-17")); - assert_eq!(claims["sub"], json!("urn:example:client-a")); - assert!(claims.get("evidence_tags").is_none()); - assert!(claims.get("evidence_audience").is_none()); - assert_eq!( - minted.scope.as_deref(), - Some("registry:business:read registry:business:lookup") - ); - assert_eq!( - serde_json::to_value(&minted).expect("token response serializes")["scope"], - json!("registry:business:read registry:business:lookup") - ); - } - - #[tokio::test] - async fn a_listed_authority_claim_is_minted_as_a_json_array() { - let fixture = fixture(None).await; - let client_path = fixture._directory.path().join("clients/client-a.yaml"); - fs::write( - &client_path, - format!( - "clientId: client-a\nprincipal: urn:example:client-a\nauthorization:\n scopes: [registry:business:read]\n claims:\n purpose: statutory-consultation\n authority: [district-17, district-18]\nkeys: [{}]\n", - client_key(1, "client-a-1").1 - ), - ) - .expect("write scoped client"); - let registry = ClientRegistry::load( - client_path - .parent() - .expect("the registration has a parent directory"), - ) - .expect("scoped registry loads"); - let client = registry.get("client-a").expect("client registered"); - - let minted = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - let claims = decode_claims(&minted.access_token); - - assert_eq!(claims["authority"], json!(["district-17", "district-18"])); - assert_eq!(claims["purpose"], json!("statutory-consultation")); - } - - #[tokio::test] - async fn the_header_names_the_active_key_and_access_token_type() { - let fixture = fixture(None).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - - let header = decode_header(&minted.access_token); - assert_eq!(header["alg"], json!("ES256")); - assert_eq!(header["typ"], json!("at+jwt")); - assert_eq!( - header["kid"].as_str().map(str::len), - Some(43), - "service kid is an RFC 7638 SHA-256 thumbprint" - ); - } - - #[tokio::test] - async fn a_grant_is_minted_as_a_matched_pair_or_not_at_all() { - let without = fixture(None).await; - let client = without.registry.get("client-a").expect("client registered"); - let claims = decode_claims( - &without - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints") - .access_token, - ); - assert!(claims.get("evidence_grant_id").is_none()); - assert!(claims.get("evidence_authority").is_none()); - - let with = fixture(Some("{id: grant-1, authority: statute-7}")).await; - let client = with.registry.get("client-a").expect("client registered"); - let claims = decode_claims( - &with - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints") - .access_token, - ); - assert_eq!(claims["evidence_grant_id"], json!("grant-1")); - assert_eq!(claims["evidence_authority"], json!("statute-7")); - } - - #[tokio::test] - async fn every_token_carries_a_distinct_identifier() { - let fixture = fixture(None).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let first = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - let second = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - - let first_jti = decode_claims(&first.access_token)["jti"].clone(); - let second_jti = decode_claims(&second.access_token)["jti"].clone(); - assert_ne!(first_jti, second_jti); - } - - const DELEGATION: &str = - "delegation:\n actors: [urn:example:agent-one]\n subjectClaims:\n given_name: identity.given_name\n birth_date: identity.birth_date\n"; - const ACTOR_CLAIM: &str = " actor: evidence_actor\n"; - - async fn delegated_fixture() -> Fixture { - build_fixture(None, DELEGATION, ACTOR_CLAIM).await - } - - fn delegation(subject: &[(&str, Value)]) -> crate::assertion::ResolvedDelegation { - crate::assertion::ResolvedDelegation::new( - "urn:example:agent-one".to_owned(), - subject - .iter() - .map(|(field, value)| ((*field).to_owned(), value.clone())) - .collect(), - ) - } - - fn delegated( - client: &std::sync::Arc, - subject: &[(&str, Value)], - ) -> AuthenticatedClient { - AuthenticatedClient { - client: std::sync::Arc::clone(client), - delegation: Some(delegation(subject)), - } - } - - /// The subject is minted at exactly the claim paths the registration - /// declared, which is what lets a resource server read it back out as the - /// selector it will not accept from the request body. - #[tokio::test] - async fn a_delegated_token_carries_the_actor_and_the_subject_at_their_declared_paths() { - let fixture = delegated_fixture().await; - let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture - .minter - .mint( - &delegated( - client, - &[ - ("given_name", json!("Amara")), - ("birth_date", json!("1998-04-02")), - ], - ), - NOW, - ) - .await - .expect("token mints"); - - let claims = decode_claims(&minted.access_token); - assert_eq!(claims["evidence_actor"], json!("urn:example:agent-one")); - assert_eq!( - claims["identity"], - json!({"given_name": "Amara", "birth_date": "1998-04-02"}) - ); - // The registry's own authority is unchanged by the delegation. - assert_eq!(claims["sub"], json!("urn:example:client-a")); - assert_eq!(claims["client_id"], json!("client-a")); - } - - /// An ordinary token from the same minter carries neither, so a resource - /// server reading the subject from the token has nothing to read. - #[tokio::test] - async fn an_undelegated_token_carries_no_actor_and_no_subject() { - let fixture = delegated_fixture().await; - let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture - .minter - .mint(&undelegated(client), NOW) - .await - .expect("token mints"); - - let claims = decode_claims(&minted.access_token); - assert!(claims.get("evidence_actor").is_none()); - assert!(claims.get("identity").is_none()); - } - - /// Startup refuses a registry that declares delegation without a configured - /// actor claim, so this can only be reached by a bug. It must fail rather - /// than mint a token whose subject no resource server can attribute. - #[tokio::test] - async fn minting_a_delegation_without_a_configured_actor_claim_is_a_server_error() { - let fixture = build_fixture(None, DELEGATION, "").await; - let client = fixture.registry.get("client-a").expect("client registered"); - let error = fixture - .minter - .mint( - &delegated( - client, - &[ - ("given_name", json!("Amara")), - ("birth_date", json!("1998-04-02")), - ], - ), - NOW, - ) - .await - .expect_err("an unconfigured actor claim must not mint"); - assert_eq!( - error, - TokenError::server_error("no actor claim is configured for delegated tokens") - ); - } - - #[tokio::test] - async fn a_delegation_resolved_for_an_undelegated_client_is_a_server_error() { - let fixture = build_fixture(None, "", ACTOR_CLAIM).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let error = fixture - .minter - .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) - .await - .expect_err("an undelegated client must not mint a delegation"); - assert_eq!( - error, - TokenError::server_error("a delegation was resolved for an undelegated client") - ); - } - - /// Two fields under one path prefix have to nest into one object rather than - /// the second overwriting the first. - #[tokio::test] - async fn subject_claims_sharing_a_path_prefix_nest_into_one_object() { - let deep = "delegation:\n subjectClaims:\n given_name: subject.identity.given_name\n region: subject.residence.region\n"; - let fixture = build_fixture(None, deep, ACTOR_CLAIM).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let minted = fixture - .minter - .mint( - &delegated( - client, - &[("given_name", json!("Amara")), ("region", json!("north"))], - ), - NOW, - ) - .await - .expect("token mints"); - - assert_eq!( - decode_claims(&minted.access_token)["subject"], - json!({"identity": {"given_name": "Amara"}, "residence": {"region": "north"}}) - ); - } - - /// The startup check refuses a subject path rooted at an authority claim, so - /// this is unreachable in a loaded server. If it were ever reached, the - /// delegation must not be allowed to overwrite the authority. - #[tokio::test] - async fn a_subject_path_colliding_with_an_authority_claim_is_a_server_error() { - let colliding = - "delegation:\n subjectClaims:\n given_name: evidence_audience.given_name\n"; - let fixture = build_fixture(None, colliding, ACTOR_CLAIM).await; - let client = fixture.registry.get("client-a").expect("client registered"); - let error = fixture - .minter - .mint(&delegated(client, &[("given_name", json!("Amara"))]), NOW) - .await - .expect_err("a colliding subject path must not mint"); - assert_eq!( - error, - TokenError::server_error("a subject claim path collides with an authority claim") - ); - } - - #[tokio::test] - async fn the_published_key_set_carries_public_material_only() { - let fixture = fixture(None).await; - let rendered = serde_json::to_string(fixture.minter.jwks()).expect("jwks serializes"); - for member in [ - "\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"", "\"k\"", - ] { - assert!( - !rendered.contains(member), - "the published key set must not contain {member}" - ); - } - assert!(rendered.contains("ES256")); - } - - #[tokio::test] - async fn debug_output_never_reveals_the_signing_key() { - let fixture = fixture(None).await; - let rendered = format!("{:?}", fixture.minter); - - // Useful for operators: which key is live, and under what identity. - let kid = fixture.minter.signer.key_id(); - assert!(rendered.contains(kid)); - assert!(rendered.contains("http://127.0.0.1:8081")); - - // The private scalar of the fixture's signing key, verbatim. Debug is - // the easiest place for key material to escape into a log line. - let private_scalar = URL_SAFE_NO_PAD.encode([9u8; 32]); - assert!( - !rendered.contains(&private_scalar), - "the debug output must never carry private key material" - ); - } - - struct RecoverableSigner { - inner: LocalJwkSigner, - available: AtomicBool, - ready: AtomicBool, - attempts: AtomicUsize, - } - - impl RecoverableSigner { - fn new() -> Self { - let (private, _) = p256_key(9); - let private = PrivateJwk::parse(&private).expect("private JWK parses"); - Self { - inner: LocalJwkSigner::new(private).expect("test signer builds"), - available: AtomicBool::new(true), - ready: AtomicBool::new(true), - attempts: AtomicUsize::new(0), - } - } - - fn set_available(&self, available: bool) { - self.available.store(available, Ordering::Release); - } - - fn attempts(&self) -> usize { - self.attempts.load(Ordering::Acquire) - } - } - - #[async_trait::async_trait] - impl SigningProvider for RecoverableSigner { - fn algorithm(&self) -> SigningAlgorithm { - self.inner.algorithm() - } - - fn key_id(&self) -> &str { - self.inner.key_id() - } - - fn public_jwk(&self) -> PublicJwk { - self.inner.public_jwk() - } - - fn readiness(&self) -> KeyReadiness { - if self.ready.load(Ordering::Acquire) { - KeyReadiness::Ready - } else { - KeyReadiness::NotReady - } - } - - async fn sign( - &self, - payload: &[u8], - ) -> Result, registry_platform_crypto::SigningError> { - self.attempts.fetch_add(1, Ordering::AcqRel); - if !self.available.load(Ordering::Acquire) { - self.ready.store(false, Ordering::Release); - return Err(SigningError::external("provider-token-secret")); - } - let result = self.inner.sign(payload).await; - self.ready.store(result.is_ok(), Ordering::Release); - result - } - } - - #[tokio::test] - async fn readiness_probes_recover_the_provider_without_exposing_failures() { - let mut fixture = fixture(None).await; - let signer = Arc::new(RecoverableSigner::new()); - fixture.minter.signer = signer.clone(); - assert!(fixture.minter.ready().await, "the signer starts ready"); - assert_eq!(signer.attempts(), 0, "a ready signer is not probed"); - - let client = fixture.registry.get("client-a").expect("client registered"); - let authenticated = undelegated(client); - signer.set_available(false); - let error = fixture - .minter - .mint(&authenticated, NOW) - .await - .expect_err("provider loss fails the request"); - assert_eq!(signer.readiness(), KeyReadiness::NotReady); - assert!( - !format!("{error:?}").contains("provider-token-secret"), - "provider details must not escape through the request error" - ); - assert!( - !fixture.minter.ready().await, - "a failed recovery probe remains unready" - ); - - signer.set_available(true); - assert!( - fixture.minter.ready().await, - "readiness proves provider recovery without request traffic" - ); - assert_eq!(signer.readiness(), KeyReadiness::Ready); - - signer.set_available(false); - fixture - .minter - .mint(&authenticated, NOW) - .await - .expect_err("a second provider loss fails closed"); - signer.set_available(true); - fixture - .minter - .mint(&authenticated, NOW) - .await - .expect("request-path signing also recovers readiness"); - assert_eq!(signer.readiness(), KeyReadiness::Ready); - } -} diff --git a/crates/registry-mint/tests/client_secret_cli.rs b/crates/registry-mint/tests/client_secret_cli.rs deleted file mode 100644 index 97e9976826..0000000000 --- a/crates/registry-mint/tests/client_secret_cli.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Process-boundary contract for owner-only client-secret provisioning. - -use std::{fs, os::unix::fs::PermissionsExt as _, process::Command}; - -use registry_platform_authcommon::verify_api_key; - -#[test] -fn generation_prints_only_the_fingerprint_and_never_replaces_the_secret() { - let directory = tempfile::tempdir().expect("temp dir"); - let secret_path = directory.path().join("qgis-client-secret"); - - let first = Command::new(env!("CARGO_BIN_EXE_mint")) - .args(["client-secret", "generate", "--out"]) - .arg(&secret_path) - .output() - .expect("the Mint binary runs"); - assert!( - first.status.success(), - "generation failed: {}", - String::from_utf8_lossy(&first.stderr) - ); - - let fingerprint = String::from_utf8(first.stdout) - .expect("fingerprint is UTF-8") - .trim() - .to_owned(); - let secret = fs::read_to_string(&secret_path).expect("secret reads"); - let secret = secret.trim_end(); - assert_eq!(verify_api_key(secret, &fingerprint), Ok(true)); - assert!(!fingerprint.contains(secret)); - assert_eq!( - fs::metadata(&secret_path) - .expect("metadata") - .permissions() - .mode() - & 0o777, - 0o600 - ); - - let second = Command::new(env!("CARGO_BIN_EXE_mint")) - .args(["client-secret", "generate", "--out"]) - .arg(&secret_path) - .output() - .expect("the Mint binary runs again"); - assert!(!second.status.success()); - assert!( - second.stdout.is_empty(), - "a refusal printed credential output" - ); - assert_eq!( - fs::read_to_string(secret_path).expect("original secret remains"), - format!("{secret}\n") - ); -} diff --git a/crates/registry-mint/tests/client_secret_compatibility.rs b/crates/registry-mint/tests/client_secret_compatibility.rs deleted file mode 100644 index a6e1f98e8b..0000000000 --- a/crates/registry-mint/tests/client_secret_compatibility.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! End-to-end proof of Mint's compatible Client Credentials profile. -//! -//! The real router loads a standard-authority registration from disk, -//! authenticates both OAuth client-secret wire methods, mints the same bounded -//! authority for each, rotates and revokes the credential through registry -//! reloads, and keeps raw credentials out of the audit chain. - -use std::{fs, os::unix::fs::PermissionsExt as _, path::Path, sync::Arc}; - -use axum_test::{TestResponse, TestServer}; -use base64::{ - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, - Engine as _, -}; -use registry_mint::{ - config::MintConfig, - server::{build_app, MintService}, -}; -use registry_platform_authcommon::fingerprint_api_key; -use registry_platform_crypto::PublicJwk; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; -use serde_json::{json, Value}; - -const ISSUER: &str = "http://127.0.0.1:18182"; -const CLIENT_ID: &str = "qgis-installation"; -const CLIENT_SECRET: &str = "M7vEwCZZ5R2UjUVn5tQJ8w23F4w7T6s8d9P0yK1mN2o"; -const ROTATED_SECRET: &str = "w9B6R7mU3K2zY4V8nX1sA5pC0dE6fG7hJ8kL9qT2uI0"; -const RELAY_AUDIENCE: &str = "relay-qgis"; -const RELAY_SCOPE: &str = "registry:qgis:premises:read"; -const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; - -fn service_key_pair(seed: u8) -> (Value, Value) { - let scalar = [seed; 32]; - let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); - let encoded = signing.verifying_key().to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); - let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); - let bare = PublicJwk::parse( - &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), - ) - .expect("public JWK parses"); - let kid = bare.jkt().expect("thumbprint computes"); - ( - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), - ) -} - -fn write_owner_only(path: &Path, contents: &[u8]) { - fs::write(path, contents).expect("write secret file"); - fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("restrict secret file"); -} - -fn write_registration(root: &Path, secrets: &[&str]) { - let fingerprints = secrets - .iter() - .map(|secret| format!(" - {}", fingerprint_api_key(secret))) - .collect::>() - .join("\n"); - fs::write( - root.join("clients/qgis-installation.yaml"), - format!( - "clientId: {CLIENT_ID}\nprincipal: urn:example:managed-client:{CLIENT_ID}\nauthorization:\n scopes: [{RELAY_SCOPE}]\n claims:\n purpose: map-consultation\nclientAuthentication:\n method: client-secret\n secretFingerprints:\n{fingerprints}\n" - ), - ) - .expect("write client registration"); -} - -struct Deployment { - _directory: tempfile::TempDir, - root: std::path::PathBuf, - service: Arc, -} - -async fn deployment() -> Deployment { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path().to_path_buf(); - fs::create_dir(root.join("secrets")).expect("create secrets directory"); - fs::create_dir(root.join("clients")).expect("create clients directory"); - fs::create_dir(root.join("public-keys")).expect("create public key directory"); - - let (signing_public, signing_private) = service_key_pair(11); - let public_file = format!( - "{}.jwk.json", - signing_public["kid"].as_str().expect("service key id") - ); - fs::write( - root.join("public-keys").join(&public_file), - signing_public.to_string(), - ) - .expect("write public key"); - write_owner_only( - &root.join("secrets/signing.jwk"), - signing_private.to_string().as_bytes(), - ); - write_owner_only(&root.join("secrets/audit-hmac-key"), AUDIT_HASH_KEY); - write_registration(&root, &[CLIENT_SECRET]); - - let config_path = root.join("mint.yaml"); - fs::write( - &config_path, - format!( - r#" -version: 1 -validationMode: supervised-local-development -issuer: {ISSUER} -listener: {{address: 127.0.0.1, port: 18182}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{RELAY_AUDIENCE}] - lifetimeSeconds: 60 -clientAssertion: - audience: {ISSUER}/token - algorithms: [EdDSA] -clients: - directory: clients -"#, - root.join("secrets").display() - ), - ) - .expect("write Mint configuration"); - - let config = MintConfig::load(&config_path).expect("configuration loads"); - let service = Arc::new(MintService::load(config).await.expect("service loads")); - Deployment { - _directory: directory, - root, - service, - } -} - -fn basic_header(client_id: &str, secret: &str) -> String { - format!("Basic {}", STANDARD.encode(format!("{client_id}:{secret}"))) -} - -async fn request_basic(http: &TestServer, client_id: &str, secret: &str) -> TestResponse { - http.post("/token") - .add_header("authorization", basic_header(client_id, secret)) - .form(&[ - ("grant_type", "client_credentials"), - ("scope", "caller:cannot:widen"), - ]) - .await -} - -fn verifier(jwks: &Value) -> TokenVerifier { - let key_set: jsonwebtoken::jwk::JwkSet = - serde_json::from_value(jwks.clone()).expect("published keys parse"); - TokenVerifier::new( - TokenVerifierConfig::access_token_profile( - ISSUER.to_owned(), - vec![RELAY_AUDIENCE.to_owned()], - vec![jsonwebtoken::Algorithm::ES256], - vec!["at+jwt".to_owned()], - ), - Arc::new(JwksFetcher::new_static( - key_set, - JwksFetcherConfig::defaults(), - )), - ) -} - -#[tokio::test] -async fn standard_clients_reacquire_fixed_authority_with_basic_or_post() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let published = http.get("/.well-known/jwks.json").await.json::(); - let verifier = verifier(&published); - - let basic = request_basic(&http, CLIENT_ID, CLIENT_SECRET).await; - basic.assert_status_ok(); - assert_eq!(basic.header("cache-control"), "no-store"); - let basic_body = basic.json::(); - assert_eq!(basic_body["token_type"], json!("Bearer")); - assert_eq!(basic_body["expires_in"], json!(60)); - assert_eq!(basic_body["scope"], json!(RELAY_SCOPE)); - assert!(basic_body.get("refresh_token").is_none()); - - let post = http - .post("/token") - .form(&[ - ("grant_type", "client_credentials"), - ("client_id", CLIENT_ID), - ("client_secret", CLIENT_SECRET), - ("scope", "caller:cannot:widen"), - ]) - .await; - post.assert_status_ok(); - let post_body = post.json::(); - assert_eq!(post_body["scope"], json!(RELAY_SCOPE)); - assert_ne!(post_body["access_token"], basic_body["access_token"]); - - for body in [&basic_body, &post_body] { - let token = body["access_token"].as_str().expect("access token"); - let verified = verifier.verify(token).await.expect("token verifies"); - assert_eq!( - verified.claims.sub.as_deref(), - Some("urn:example:managed-client:qgis-installation") - ); - assert_eq!(verified.claims.client_id.as_deref(), Some(CLIENT_ID)); - assert_eq!(verified.scopes, [RELAY_SCOPE]); - assert_eq!(verified.claims.extra["purpose"], json!("map-consultation")); - } - - let metadata = http - .get("/.well-known/oauth-authorization-server") - .await - .json::(); - assert_eq!( - metadata["token_endpoint_auth_methods_supported"], - json!([ - "private_key_jwt", - "client_secret_basic", - "client_secret_post" - ]) - ); - - let wrong = request_basic(&http, CLIENT_ID, "wrong-secret").await; - let unknown = request_basic(&http, "unknown-installation", CLIENT_SECRET).await; - wrong.assert_status_unauthorized(); - unknown.assert_status_unauthorized(); - assert_eq!(wrong.json::(), json!({"error": "invalid_client"})); - assert_eq!(unknown.json::(), json!({"error": "invalid_client"})); - assert_eq!( - wrong.header("www-authenticate").to_str().expect("header"), - "Basic realm=\"registry-mint\"" - ); - - let audit = - fs::read_to_string(deployment.root.join("audit/mint.jsonl")).expect("audit chain reads"); - for sensitive in [CLIENT_ID, CLIENT_SECRET, ROTATED_SECRET] { - assert!( - !audit.contains(sensitive), - "audit exposed client credential material" - ); - } -} - -#[tokio::test] -async fn rotation_and_reload_revoke_only_future_token_requests() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let published = http.get("/.well-known/jwks.json").await.json::(); - let verifier = verifier(&published); - - let issued = request_basic(&http, CLIENT_ID, CLIENT_SECRET).await; - issued.assert_status_ok(); - let retained = issued.json::()["access_token"] - .as_str() - .expect("access token") - .to_owned(); - - write_registration(&deployment.root, &[CLIENT_SECRET, ROTATED_SECRET]); - assert_eq!( - deployment - .service - .reload_clients() - .expect("rotation reloads"), - 1 - ); - request_basic(&http, CLIENT_ID, CLIENT_SECRET) - .await - .assert_status_ok(); - request_basic(&http, CLIENT_ID, ROTATED_SECRET) - .await - .assert_status_ok(); - - write_registration(&deployment.root, &[ROTATED_SECRET]); - assert_eq!( - deployment - .service - .reload_clients() - .expect("removal reloads"), - 1 - ); - request_basic(&http, CLIENT_ID, CLIENT_SECRET) - .await - .assert_status_unauthorized(); - request_basic(&http, CLIENT_ID, ROTATED_SECRET) - .await - .assert_status_ok(); - - fs::remove_file(deployment.root.join("clients/qgis-installation.yaml")) - .expect("remove registration"); - assert_eq!( - deployment - .service - .reload_clients() - .expect("revocation reloads"), - 0 - ); - request_basic(&http, CLIENT_ID, ROTATED_SECRET) - .await - .assert_status_unauthorized(); - - verifier - .verify(&retained) - .await - .expect("a previously issued token remains valid only until its expiry"); -} diff --git a/crates/registry-mint/tests/delegated_subject_binding.rs b/crates/registry-mint/tests/delegated_subject_binding.rs deleted file mode 100644 index cc2e3dee99..0000000000 --- a/crates/registry-mint/tests/delegated_subject_binding.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! End-to-end proof that a delegated token Mint issues is bound to one subject. -//! -//! The claim is narrow and worth stating precisely: a client that is registered -//! for delegation asks Mint for a token *for a named person*, and the resulting -//! token can only ever produce evidence about that person. Not because the -//! client is well behaved, but because Evidence reads the subject out of the -//! token and refuses to read it from the request at all. -//! -//! Nothing here stubs a boundary. The Mint router is the real one, the bundle is -//! the demonstration bundle under `demo/evidence-bundle`, and the authorization -//! decision is Evidence's own `match_entitlement` and `resolve_selectors`. - -use std::{collections::BTreeMap, fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; - -use axum_test::TestServer; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_evidence::{ - auth::{AuthenticatedContext, AuthenticationClaimsConfig, Authenticator}, - bundle::Bundle, - config::{AuthorityKind, ValueOrigin}, - model::{EvidenceRequest, RequestedSelector, RequestedSubject, SelectorValue}, - selector::{authorize_and_resolve, AuthorizationError, ResolvedSelectorValue}, -}; -use registry_mint::{ - config::MintConfig, - server::{build_app, MintService}, - CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, ON_BEHALF_OF_CLAIM, -}; -use registry_platform_crypto::{PrivateJwk, PublicJwk}; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; -use serde_json::{json, Value}; - -/// These four must agree with `demo/evidence-bundle/evidence.yaml`. If they ever -/// drift, this test is the thing that says so. -const ISSUER: &str = "http://127.0.0.1:8443"; -const EVIDENCE_AUDIENCE: &str = "evidence.demo.invalid"; -const ACTOR_CLAIM: &str = "evidence_actor"; -const REQUIREMENT: &str = "urn:example:demo:requirement:residence-region:v1"; -const PURPOSE: &str = "demo-routing"; -const ASSERTION_AUDIENCE: &str = "http://127.0.0.1:8443/token"; - -const AGENT: &str = "urn:example:demo:agent:appointment-scheduler"; - -// A fixed, non-secret audit HMAC key. Held as a byte literal rather than -// written inline so a secret scanner does not read the write call as an -// assignment of a live credential. -const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; - -fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let d = URL_SAFE_NO_PAD.encode(seed_bytes); - let kid = format!("key-{seed}"); - let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); - let private_document = - json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); - let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); - (private, public, private_document) -} - -fn service_key_pair(seed: u8) -> (Value, Value) { - let scalar = [seed; 32]; - let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); - let encoded = signing.verifying_key().to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); - let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); - let bare = PublicJwk::parse( - &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), - ) - .expect("public JWK parses"); - let kid = bare.jkt().expect("thumbprint computes"); - ( - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), - ) -} - -struct Deployment { - _directory: tempfile::TempDir, - service: Arc, - audit_path: std::path::PathBuf, -} - -/// A Mint deployment whose claim names, issuer, and audience are the ones the -/// demonstration bundle expects. -async fn deployment() -> Deployment { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path(); - fs::create_dir(root.join("secrets")).expect("create secrets directory"); - fs::create_dir(root.join("clients")).expect("create clients directory"); - fs::create_dir(root.join("public-keys")).expect("create public key directory"); - - let (signing_public, signing_document) = service_key_pair(9); - let public_file = format!( - "{}.jwk.json", - signing_public["kid"].as_str().expect("service key id") - ); - fs::write( - root.join("public-keys").join(&public_file), - signing_public.to_string(), - ) - .expect("write governed public key"); - let signing_path = root.join("secrets/signing.jwk"); - fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); - fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) - .expect("restrict signing key"); - let audit_key_path = root.join("secrets/audit-hmac-key"); - fs::write(&audit_key_path, AUDIT_HASH_KEY).expect("write audit key"); - fs::set_permissions(&audit_key_path, fs::Permissions::from_mode(0o600)) - .expect("restrict audit key"); - let audit_path = root.join("audit/mint.jsonl"); - - // The delegated caller: it may act as one agent, over exactly three - // selector fields, minted at exactly the paths the bundle reads. - let (_, scheduler_public, _) = key_pair(1); - fs::write( - root.join("clients/scheduler.yaml"), - format!( - "clientId: scheduler\nprincipal: urn:example:demo:principal:scheduler\nevidenceAudience: https://scheduler.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{scheduler_public}]\ndelegation:\n actors: [{AGENT}]\n subjectClaims:\n given_name: identity.given_name\n family_name: identity.family_name\n birth_date: identity.birth_date\n" - ), - ) - .expect("write delegated client"); - - // The same authority, without delegation. Its tokens carry no actor. - let (_, desk_public, _) = key_pair(2); - fs::write( - root.join("clients/service-desk.yaml"), - format!( - "clientId: service-desk\nprincipal: urn:example:demo:principal:service-desk\nevidenceAudience: https://service-desk.demo.invalid\nrequesterTags: [demo-agent]\nkeys: [{desk_public}]\n" - ), - ) - .expect("write undelegated client"); - - let config_path = root.join("mint.yaml"); - fs::write( - &config_path, - format!( - r#" -version: 1 -validationMode: supervised-local-development -issuer: {ISSUER} -listener: {{address: 127.0.0.1, port: 8443}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{EVIDENCE_AUDIENCE}] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority - actor: {ACTOR_CLAIM} -clientAssertion: - audience: {ASSERTION_AUDIENCE} - algorithms: [EdDSA] -clients: - directory: clients -"#, - root.join("secrets").display() - ), - ) - .expect("write config"); - - let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); - let service = Arc::new( - MintService::load(config) - .await - .expect("the deployment loads"), - ); - Deployment { - _directory: directory, - service, - audit_path, - } -} - -fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { - let kid = private.kid.clone().expect("the test key has a kid"); - let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); - let encode = |value: &Value| { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) - }; - let signing_input = format!("{}.{}", encode(&header), encode(claims)); - let signature = - registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); - format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - -fn assertion_claims(client_id: &str, jti: &str) -> Value { - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - json!({ - "iss": client_id, - "sub": client_id, - "aud": ASSERTION_AUDIENCE, - "iat": now, - "exp": now + 120, - "jti": jti, - }) -} - -/// The delegation request, carried inside the client's own signed assertion so -/// the values are covered by the client's signature rather than travelling as -/// unauthenticated form parameters. -fn delegated_claims(jti: &str, subject: &[(&str, &str)]) -> Value { - let mut claims = assertion_claims("scheduler", jti); - claims[ON_BEHALF_OF_CLAIM] = json!({ - "actor": AGENT, - "subject": subject - .iter() - .map(|(field, value)| ((*field).to_owned(), json!(value))) - .collect::>(), - }); - claims -} - -fn token_form(assertion: &str) -> Vec<(String, String)> { - vec![ - ( - "grant_type".to_owned(), - GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), - ), - ( - "client_assertion_type".to_owned(), - CLIENT_ASSERTION_TYPE.to_owned(), - ), - ("client_assertion".to_owned(), assertion.to_owned()), - ] -} - -async fn mint_token(http: &TestServer, assertion: &str) -> String { - let response = http.post("/token").form(&token_form(assertion)).await; - response.assert_status_ok(); - response.json::()["access_token"] - .as_str() - .expect("the response carries an access token") - .to_owned() -} - -/// Evidence's authenticator over the key set Mint published, configured exactly -/// as the demonstration bundle configures it. -fn evidence_authenticator(jwks: &Value) -> Authenticator { - let key_set: jsonwebtoken::jwk::JwkSet = - serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); - let verifier_config = TokenVerifierConfig::access_token_profile( - ISSUER.to_owned(), - vec![EVIDENCE_AUDIENCE.to_owned()], - vec![jsonwebtoken::Algorithm::ES256], - vec!["at+jwt".to_owned()], - ); - Authenticator::new( - Arc::new(TokenVerifier::new( - verifier_config, - Arc::new(JwksFetcher::new_static( - key_set, - JwksFetcherConfig::defaults(), - )), - )), - AuthenticationClaimsConfig { - principal_claim: "sub".to_owned(), - requester_tags_claim: "evidence_tags".to_owned(), - evidence_audience_claim: "evidence_audience".to_owned(), - grant_id_claim: "evidence_grant_id".to_owned(), - grant_authority_claim: "evidence_authority".to_owned(), - actor_claim: Some(ACTOR_CLAIM.to_owned()), - }, - ) -} - -struct LoadedBundle { - _directory: tempfile::TempDir, - bundle: Bundle, -} - -/// Evidence refuses a writable bundle, so the demonstration bundle is copied to -/// a temporary root and frozen before loading. -fn demo_bundle() -> LoadedBundle { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path().join("bundle"); - fs::create_dir(&root).expect("create bundle root"); - copy_tree( - &Path::new(env!("CARGO_MANIFEST_DIR")).join("demo/evidence-bundle"), - &root, - ); - make_read_only(&root); - let bundle = Bundle::load(&root).expect("the demonstration bundle loads"); - LoadedBundle { - _directory: directory, - bundle, - } -} - -fn copy_tree(source: &Path, target: &Path) { - for entry in fs::read_dir(source).expect("the demonstration bundle is readable") { - let entry = entry.expect("bundle entry is readable"); - let destination = target.join(entry.file_name()); - if entry.file_type().expect("entry type is readable").is_dir() { - fs::create_dir(&destination).expect("bundle directory is copied"); - copy_tree(&entry.path(), &destination); - } else { - fs::copy(entry.path(), destination).expect("bundle file is copied"); - } - } -} - -fn make_read_only(path: &Path) { - for entry in fs::read_dir(path).expect("copied bundle is readable") { - let entry = entry.expect("bundle entry is readable"); - let child = entry.path(); - if entry.file_type().expect("entry type is readable").is_dir() { - make_read_only(&child); - fs::set_permissions(&child, fs::Permissions::from_mode(0o555)) - .expect("bundle directory is immutable"); - } else { - fs::set_permissions(&child, fs::Permissions::from_mode(0o444)) - .expect("bundle file is immutable"); - } - } - fs::set_permissions(path, fs::Permissions::from_mode(0o555)).expect("bundle root is immutable"); -} - -/// The request a delegated caller sends: it names the requirement, the purpose, -/// and the *shape* of the subject, and carries no selector values at all. -fn subject_bound_request() -> EvidenceRequest { - EvidenceRequest { - // The nonce is a caller correlation value that never reaches - // authorization, which is the only thing under test here. - request_nonce: registry_evidence::model::OFFLINE_EVALUATION_REQUEST_NONCE.to_owned(), - requirement: REQUIREMENT.to_owned(), - purpose: PURPOSE.to_owned(), - subjects: vec![RequestedSubject { - role: "subject".to_owned(), - selector: RequestedSelector { - profile: "demographics-v1".to_owned(), - values: None, - }, - }], - // Under the audience-scoped mode this requirement uses, a holder key - // belongs to the SD-JWT VC response format and never reaches - // authorization, which is the only thing under test here. - holder_keys: Vec::new(), - } -} - -fn resolved_values( - authorization: ®istry_evidence::selector::ResolvedAuthorization, -) -> BTreeMap { - authorization - .subjects - .iter() - .flat_map(|subject| subject.fields.iter()) - .map(|field| { - let value = match &field.value { - ResolvedSelectorValue::String(value) => value.clone(), - ResolvedSelectorValue::Date(value) => value.to_string(), - other => format!("{other:?}"), - }; - (field.name.clone(), value) - }) - .collect() -} - -async fn context_for(http: &TestServer, jwks: &Value, assertion: &str) -> AuthenticatedContext { - let token = mint_token(http, assertion).await; - evidence_authenticator(jwks) - .authenticate(&token) - .await - .expect("Evidence accepts a token Mint issued") -} - -/// The whole point, in one test: the subject the client named to Mint is the -/// subject Evidence resolves, and the client never names it again. -#[tokio::test] -async fn a_delegated_token_authorizes_evidence_about_exactly_its_own_subject() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let jwks = http.get("/.well-known/jwks.json").await.json::(); - let loaded = demo_bundle(); - - let (private, _, _) = key_pair(1); - let assertion = sign_assertion( - &private, - &delegated_claims( - "jti-1", - &[ - ("given_name", "Amara"), - ("family_name", "Okafor"), - ("birth_date", "1998-04-02"), - ], - ), - ); - let context = context_for(&http, &jwks, &assertion).await; - assert_eq!(context.actor(), Some(AGENT)); - - let audit = fs::read_to_string(&deployment.audit_path).expect("read Mint audit"); - assert!(audit.contains("\"phase\":\"token-release\"")); - assert!(audit.contains("\"decision\":\"issued\"")); - assert!(audit.contains("\"clientPseudonym\":\"hmac-sha256:v1:")); - assert!(audit.contains("\"authorityPseudonym\":\"hmac-sha256:v1:")); - assert!(audit.contains("\"subjectPseudonym\":\"hmac-sha256:v1:")); - for protected in [ - "scheduler", - "urn:example:demo:principal:scheduler", - AGENT, - "Amara", - "Okafor", - "1998-04-02", - &assertion, - ] { - assert!( - !audit.contains(protected), - "Mint audit retained protected token input" - ); - } - - let authorization = authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) - .expect("a delegated token authorizes its own subject"); - - assert_eq!(authorization.authority_kind, AuthorityKind::Delegated); - assert_eq!(authorization.subjects.len(), 1); - assert_eq!( - authorization.subjects[0].value_origin, - ValueOrigin::AuthenticatedContext - ); - assert_eq!( - resolved_values(&authorization), - BTreeMap::from([ - ("given_name".to_owned(), "Amara".to_owned()), - ("family_name".to_owned(), "Okafor".to_owned()), - ("birth_date".to_owned(), "1998-04-02".to_owned()), - ]) - ); -} - -/// The containment Jeremi asked for. A client holding a token for one person -/// cannot reach a second person by putting their details in the request: the -/// request is refused for carrying selector values at all, so there is no -/// version of this request that reaches a different subject. -#[tokio::test] -async fn a_delegated_token_cannot_be_pointed_at_a_different_subject() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let jwks = http.get("/.well-known/jwks.json").await.json::(); - let loaded = demo_bundle(); - - let (private, _, _) = key_pair(1); - let assertion = sign_assertion( - &private, - &delegated_claims( - "jti-1", - &[ - ("given_name", "Amara"), - ("family_name", "Okafor"), - ("birth_date", "1998-04-02"), - ], - ), - ); - let context = context_for(&http, &jwks, &assertion).await; - - let mut request = subject_bound_request(); - request.subjects[0].selector.values = Some(BTreeMap::from([ - ( - "given_name".to_owned(), - SelectorValue::String("Kofi".to_owned()), - ), - ( - "family_name".to_owned(), - SelectorValue::String("Mensah".to_owned()), - ), - ( - "birth_date".to_owned(), - SelectorValue::String("1971-11-30".to_owned()), - ), - ])); - - let error = authorize_and_resolve(&loaded.bundle, &request, &context) - .expect_err("a request carrying its own selector values is refused"); - assert_eq!(error, AuthorizationError::Selector); - - // And repeating the caller's own subject does not help either: the refusal - // is for supplying values, not for supplying the wrong ones. - let mut echoed = subject_bound_request(); - echoed.subjects[0].selector.values = Some(BTreeMap::from([ - ( - "given_name".to_owned(), - SelectorValue::String("Amara".to_owned()), - ), - ( - "family_name".to_owned(), - SelectorValue::String("Okafor".to_owned()), - ), - ( - "birth_date".to_owned(), - SelectorValue::String("1998-04-02".to_owned()), - ), - ])); - assert_eq!( - authorize_and_resolve(&loaded.bundle, &echoed, &context) - .expect_err("supplying values is refused even when they match"), - AuthorizationError::Selector - ); -} - -/// Two tokens from the same client and the same key resolve to two different -/// people, so the binding is a property of the token rather than of the client. -#[tokio::test] -async fn each_token_carries_its_own_subject() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let jwks = http.get("/.well-known/jwks.json").await.json::(); - let loaded = demo_bundle(); - let (private, _, _) = key_pair(1); - - let first = sign_assertion( - &private, - &delegated_claims( - "jti-1", - &[ - ("given_name", "Amara"), - ("family_name", "Okafor"), - ("birth_date", "1998-04-02"), - ], - ), - ); - let second = sign_assertion( - &private, - &delegated_claims( - "jti-2", - &[ - ("given_name", "Kofi"), - ("family_name", "Mensah"), - ("birth_date", "1971-11-30"), - ], - ), - ); - - let first = context_for(&http, &jwks, &first).await; - let second = context_for(&http, &jwks, &second).await; - - let resolve = |context: &AuthenticatedContext| { - resolved_values( - &authorize_and_resolve(&loaded.bundle, &subject_bound_request(), context) - .expect("each delegated token authorizes its own subject"), - ) - }; - assert_eq!(resolve(&first)["given_name"], "Amara"); - assert_eq!(resolve(&second)["given_name"], "Kofi"); -} - -/// A token with no actor cannot produce evidence from the subject-bound grant, -/// because there is nowhere for the subject to come from: the grant reads it -/// from claims the token does not carry, and refuses to read it from the -/// request. -/// -/// Note the shape of the refusal. Evidence confines an *actor-bearing* token to -/// `kind: delegated` profiles, but it does not conversely require an actor to -/// reach one, so this token matches the grant and is stopped at selector -/// resolution rather than at entitlement matching. Nothing leaks either way, but -/// the two are not interchangeable: were this grant to gain a subject role whose -/// values come from the request, an undelegated token would reach it. -#[tokio::test] -async fn an_undelegated_token_cannot_use_the_delegated_grant() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let jwks = http.get("/.well-known/jwks.json").await.json::(); - let loaded = demo_bundle(); - - let (private, _, _) = key_pair(2); - let assertion = sign_assertion(&private, &assertion_claims("service-desk", "jti-1")); - let context = context_for(&http, &jwks, &assertion).await; - assert_eq!(context.actor(), None); - - assert_eq!( - authorize_and_resolve(&loaded.bundle, &subject_bound_request(), &context) - .expect_err("an undelegated token has no subject to resolve"), - AuthorizationError::Selector - ); - - // Supplying the subject in the request does not rescue it: the grant refuses - // request-borne selector values from any caller. - let mut request = subject_bound_request(); - request.subjects[0].selector.values = Some(BTreeMap::from([ - ( - "given_name".to_owned(), - SelectorValue::String("Amara".to_owned()), - ), - ( - "family_name".to_owned(), - SelectorValue::String("Okafor".to_owned()), - ), - ( - "birth_date".to_owned(), - SelectorValue::String("1998-04-02".to_owned()), - ), - ])); - assert_eq!( - authorize_and_resolve(&loaded.bundle, &request, &context) - .expect_err("an undelegated token cannot name a subject either"), - AuthorizationError::Selector - ); -} - -/// Mint refuses the request before it ever becomes a token: the actor is not one -/// this registration may act as. -#[tokio::test] -async fn mint_refuses_an_actor_the_registration_does_not_permit() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(1); - let mut claims = delegated_claims( - "jti-1", - &[ - ("given_name", "Amara"), - ("family_name", "Okafor"), - ("birth_date", "1998-04-02"), - ], - ); - claims[ON_BEHALF_OF_CLAIM]["actor"] = json!("urn:example:demo:agent:someone-else"); - let assertion = sign_assertion(&private, &claims); - - let response = http.post("/token").form(&token_form(&assertion)).await; - assert_eq!(response.status_code(), 401); - assert_eq!(response.json::(), json!({"error": "invalid_client"})); - let audit = fs::read_to_string(&deployment.audit_path).expect("read Mint audit"); - assert!(audit.contains("\"phase\":\"denial\"")); - assert!(audit.contains("\"safeErrorCategory\":\"invalid_client\"")); - assert!(!audit.contains("someone-else")); -} - -/// A signed access token is not released unless its audit record is durable. -#[tokio::test] -async fn an_unwritable_audit_chain_prevents_token_release() { - let deployment = deployment().await; - fs::set_permissions(&deployment.audit_path, fs::Permissions::from_mode(0o400)) - .expect("make audit unwritable"); - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(2); - let assertion = sign_assertion( - &private, - &assertion_claims("service-desk", "jti-audit-down"), - ); - let response = http.post("/token").form(&token_form(&assertion)).await; - assert_eq!(response.status_code(), 500); - assert_eq!(response.json::(), json!({"error": "server_error"})); - - let readiness = http.get("/ready").await; - assert_eq!(readiness.status_code(), 503); -} - -/// The other direction: an undelegated registration cannot obtain a subject -/// binding by asking for one. -#[tokio::test] -async fn mint_refuses_a_delegation_from_an_undelegated_client() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(2); - let mut claims = assertion_claims("service-desk", "jti-1"); - claims[ON_BEHALF_OF_CLAIM] = json!({ - "actor": AGENT, - "subject": {"given_name": "Amara", "family_name": "Okafor", "birth_date": "1998-04-02"}, - }); - let assertion = sign_assertion(&private, &claims); - - let response = http.post("/token").form(&token_form(&assertion)).await; - assert_eq!(response.status_code(), 401); - assert_eq!(response.json::(), json!({"error": "invalid_client"})); -} diff --git a/crates/registry-mint/tests/evidence_compatibility.rs b/crates/registry-mint/tests/evidence_compatibility.rs deleted file mode 100644 index 5808c4d955..0000000000 --- a/crates/registry-mint/tests/evidence_compatibility.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! End-to-end proof that a token Mint issues is one Evidence accepts. -//! -//! This is the test that justifies the crate existing. It drives the real Mint -//! router over a real deployment on disk, and feeds the resulting access token -//! to the real Evidence authenticator. Nothing here stubs a boundary: if the -//! two products ever disagree about claim names, algorithms, token type, -//! issuer, or audience, this fails. - -use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path, sync::Arc, time::Duration}; - -use axum_test::TestServer; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_evidence::{ - auth::{AuthenticationClaimsConfig, Authenticator}, - config::{ - AccessTokenAlgorithm, AccessTokenType, AssuranceProfile, AuthenticationConfig, - AuthenticationKind, - }, -}; -use registry_mint::{ - config::MintConfig, - server::{build_app, serve, MintService}, - CLIENT_ASSERTION_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS, -}; -use registry_platform_crypto::{PrivateJwk, PublicJwk}; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifier, TokenVerifierConfig}; -use serde_json::{json, Value}; - -// A fixed, non-secret audit HMAC key. Held as a byte literal rather than -// written inline so a secret scanner does not read the write call as an -// assignment of a live credential. -const AUDIT_HASH_KEY: &[u8] = b"0123456789abcdef0123456789abcdef"; -const ISSUER: &str = "http://127.0.0.1:18082"; -const ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18082/token"; -const LOCAL_ISSUER: &str = "http://127.0.0.1:18081"; -const LOCAL_ASSERTION_AUDIENCE: &str = "http://127.0.0.1:18081/token"; -const EVIDENCE_AUDIENCE: &str = "evidence.example.org"; - -/// The claim names shared by the two configuration documents. Evidence reads -/// exactly these, and Mint writes exactly these. -const PRINCIPAL_CLAIM: &str = "sub"; -const REQUESTER_TAGS_CLAIM: &str = "evidence_tags"; -const EVIDENCE_AUDIENCE_CLAIM: &str = "evidence_audience"; -const GRANT_ID_CLAIM: &str = "evidence_grant_id"; -const GRANT_AUTHORITY_CLAIM: &str = "evidence_authority"; - -/// Deterministic Ed25519 material, so a test can hold several distinct -/// identities and know which one signed what. -fn key_pair(seed: u8) -> (PrivateJwk, Value, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let d = URL_SAFE_NO_PAD.encode(seed_bytes); - let kid = format!("key-{seed}"); - let public = json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}); - let private_document = - json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, "d": d}); - let private = PrivateJwk::parse(&private_document.to_string()).expect("private JWK parses"); - (private, public, private_document) -} - -fn service_key_pair(seed: u8) -> (Value, Value) { - let scalar = [seed; 32]; - let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); - let encoded = signing.verifying_key().to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); - let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); - let bare = PublicJwk::parse( - &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), - ) - .expect("public JWK parses"); - let kid = bare.jkt().expect("thumbprint computes"); - ( - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), - ) -} - -struct Deployment { - /// Held so the directory outlives the service that reads from it. - _directory: tempfile::TempDir, - service: Arc, -} - -/// Write a complete Mint deployment to disk and load it exactly as the binary -/// would, including the owner-only permission requirement on the signing key. -async fn deployment() -> Deployment { - deployment_with_transport( - Some("supervised-local-development"), - ISSUER, - 18082, - ASSERTION_AUDIENCE, - ) - .await -} - -async fn supervised_local_development_deployment() -> Deployment { - deployment_with_transport( - Some("supervised-local-development"), - LOCAL_ISSUER, - 18081, - LOCAL_ASSERTION_AUDIENCE, - ) - .await -} - -async fn deployment_with_transport( - validation_mode: Option<&str>, - issuer: &str, - listener_port: u16, - assertion_audience: &str, -) -> Deployment { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path(); - fs::create_dir(root.join("secrets")).expect("create secrets directory"); - fs::create_dir(root.join("clients")).expect("create clients directory"); - fs::create_dir(root.join("public-keys")).expect("create public key directory"); - - let (signing_public, signing_document) = service_key_pair(9); - let public_file = format!( - "{}.jwk.json", - signing_public["kid"].as_str().expect("service key id") - ); - fs::write( - root.join("public-keys").join(&public_file), - signing_public.to_string(), - ) - .expect("write governed public key"); - let signing_path = root.join("secrets/signing.jwk"); - fs::write(&signing_path, signing_document.to_string()).expect("write signing key"); - fs::set_permissions(&signing_path, fs::Permissions::from_mode(0o600)) - .expect("restrict signing key"); - let audit_key_path = root.join("secrets/audit-hmac-key"); - fs::write(&audit_key_path, AUDIT_HASH_KEY).expect("write audit key"); - fs::set_permissions(&audit_key_path, fs::Permissions::from_mode(0o600)) - .expect("restrict audit key"); - - let (_, health_public, _) = key_pair(1); - write_client( - root, - "health-ministry", - &health_public, - Some(("grant-7", "statute-12")), - ); - let (_, statistics_public, _) = key_pair(2); - write_client(root, "statistics-office", &statistics_public, None); - - let config_path = root.join("mint.yaml"); - let validation_mode = validation_mode - .map(|mode| format!("validationMode: {mode}\n")) - .unwrap_or_default(); - fs::write( - &config_path, - format!( - r#" -version: 1 -{validation_mode}issuer: {issuer} -listener: {{address: 127.0.0.1, port: {listener_port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{EVIDENCE_AUDIENCE}] - lifetimeSeconds: 300 - claims: - principal: {PRINCIPAL_CLAIM} - requesterTags: {REQUESTER_TAGS_CLAIM} - evidenceAudience: {EVIDENCE_AUDIENCE_CLAIM} - grantId: {GRANT_ID_CLAIM} - grantAuthority: {GRANT_AUTHORITY_CLAIM} -clientAssertion: - audience: {assertion_audience} - algorithms: [EdDSA] -clients: - directory: clients -"#, - root.join("secrets").display() - ), - ) - .expect("write config"); - - let config = MintConfig::load(&config_path).expect("the deployment configuration is valid"); - let service = Arc::new( - MintService::load(config) - .await - .expect("the deployment loads"), - ); - Deployment { - _directory: directory, - service, - } -} - -fn write_client(root: &Path, client_id: &str, public: &Value, grant: Option<(&str, &str)>) { - let mut document = format!( - "clientId: {client_id}\nprincipal: urn:example:{client_id}\nevidenceAudience: https://{client_id}.example.org\nrequesterTags: [{client_id}]\nkeys: [{public}]\n" - ); - if let Some((id, authority)) = grant { - document.push_str(&format!("grant: {{id: {id}, authority: {authority}}}\n")); - } - fs::write(root.join(format!("clients/{client_id}.yaml")), document) - .expect("write client registration"); -} - -fn sign_assertion(private: &PrivateJwk, claims: &Value) -> String { - let kid = private.kid.clone().expect("the test key has a kid"); - let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}); - let encode = |value: &Value| { - URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("value serializes")) - }; - let signing_input = format!("{}.{}", encode(&header), encode(claims)); - let signature = - registry_platform_crypto::sign(signing_input.as_bytes(), private).expect("the key signs"); - format!("{signing_input}.{}", URL_SAFE_NO_PAD.encode(signature)) -} - -fn assertion_claims(client_id: &str, jti: &str) -> Value { - assertion_claims_for_audience(client_id, jti, ASSERTION_AUDIENCE) -} - -fn assertion_claims_for_audience(client_id: &str, jti: &str, audience: &str) -> Value { - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - json!({ - "iss": client_id, - "sub": client_id, - "aud": audience, - "iat": now, - "exp": now + 120, - "jti": jti, - }) -} - -fn token_form(assertion: &str) -> Vec<(String, String)> { - vec![ - ( - "grant_type".to_owned(), - GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), - ), - ( - "client_assertion_type".to_owned(), - CLIENT_ASSERTION_TYPE.to_owned(), - ), - ("client_assertion".to_owned(), assertion.to_owned()), - ] -} - -fn token_form_with_client_id(client_id: &str, assertion: &str) -> Vec<(String, String)> { - let mut form = token_form(assertion); - form.push(("client_id".to_owned(), client_id.to_owned())); - form -} - -/// Build the Evidence authenticator the way `Authenticator::from_config` does, -/// but over the key set Mint actually published rather than an HTTPS fetch. -fn evidence_authenticator(jwks: &Value) -> Authenticator { - evidence_authenticator_for_issuer(jwks, ISSUER) -} - -fn evidence_authenticator_for_issuer(jwks: &Value, issuer: &str) -> Authenticator { - let key_set: jsonwebtoken::jwk::JwkSet = - serde_json::from_value(jwks.clone()).expect("Mint publishes a parsable JWK set"); - let verifier_config = TokenVerifierConfig::access_token_profile( - issuer.to_owned(), - vec![EVIDENCE_AUDIENCE.to_owned()], - vec![jsonwebtoken::Algorithm::ES256], - vec!["at+jwt".to_owned()], - ); - let fetcher = Arc::new(JwksFetcher::new_static( - key_set, - JwksFetcherConfig::defaults(), - )); - Authenticator::new( - Arc::new(TokenVerifier::new(verifier_config, fetcher)), - AuthenticationClaimsConfig { - principal_claim: PRINCIPAL_CLAIM.to_owned(), - requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), - evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), - grant_id_claim: GRANT_ID_CLAIM.to_owned(), - grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), - actor_claim: None, - }, - ) -} - -#[tokio::test] -async fn a_client_signing_with_its_own_key_receives_a_token_evidence_accepts() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let jwks = http.get("/.well-known/jwks.json").await; - jwks.assert_status_ok(); - assert_eq!(jwks.header("content-type"), "application/jwk-set+json"); - let published = jwks.json::(); - - let (private, _, _) = key_pair(1); - let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-1")); - // Evidence's OAuth source sends its client identifier beside the RFC 7523 - // assertion. This established request shape must remain compatible when - // Mint also supports client_secret_post. - let response = http - .post("/token") - .form(&token_form_with_client_id("health-ministry", &assertion)) - .await; - response.assert_status_ok(); - // RFC 6749 section 5.1: a token response must never be cached. - assert_eq!(response.header("cache-control"), "no-store"); - let body = response.json::(); - assert_eq!(body["token_type"], json!("Bearer")); - assert_eq!(body["expires_in"], json!(300)); - let access_token = body["access_token"] - .as_str() - .expect("the response carries an access token") - .to_owned(); - - let context = evidence_authenticator(&published) - .authenticate(&access_token) - .await - .expect("Evidence accepts a token Mint issued"); - - // Every one of these came from the server-side registry, not the assertion. - assert_eq!(context.principal(), "urn:example:health-ministry"); - assert_eq!(context.requester_tags(), ["health-ministry"]); - assert_eq!( - context.evidence_audience(), - "https://health-ministry.example.org" - ); - assert_eq!(context.grant_id(), Some("grant-7")); - assert_eq!(context.grant_authority(), Some("statute-12")); - assert_eq!(context.actor(), None); -} - -#[tokio::test] -async fn supervised_local_development_tokens_remain_evidence_compatible() { - let deployment = supervised_local_development_deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let published = http.get("/.well-known/jwks.json").await.json::(); - - let (private, _, _) = key_pair(1); - let claims = assertion_claims_for_audience( - "health-ministry", - "jti-supervised-local", - LOCAL_ASSERTION_AUDIENCE, - ); - let assertion = sign_assertion(&private, &claims); - let response = http.post("/token").form(&token_form(&assertion)).await; - response.assert_status_ok(); - let access_token = response.json::()["access_token"] - .as_str() - .expect("the response carries an access token") - .to_owned(); - - let context = evidence_authenticator_for_issuer(&published, LOCAL_ISSUER) - .authenticate(&access_token) - .await - .expect("Evidence accepts a token from the supervised local Mint mode"); - assert_eq!(context.principal(), "urn:example:health-ministry"); - assert_eq!(context.requester_tags(), ["health-ministry"]); -} - -#[tokio::test] -async fn evidence_fetches_keys_from_a_real_supervised_local_mint() { - // Hold the OS allocation while the matching deployment is authored and - // loaded. Release it only immediately before public `serve` binds it. - let reservation = - std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve a loopback port"); - let port = reservation - .local_addr() - .expect("read reserved address") - .port(); - let issuer = format!("http://127.0.0.1:{port}"); - let token_endpoint = format!("{issuer}/token"); - let jwks_uri = format!("{issuer}/.well-known/jwks.json"); - let deployment = deployment_with_transport( - Some("supervised-local-development"), - &issuer, - port, - &token_endpoint, - ) - .await; - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let service = Arc::clone(&deployment.service); - drop(reservation); - let server = tokio::spawn(async move { - serve(service, async { - let _ = shutdown_rx.await; - }) - .await - }); - - // Keep every fallible boundary inside this result so Mint is asked to shut - // down even when the real HTTP exchange or verification fails. - let proof: Result<_, Box> = async { - let client = reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(1)) - .build()?; - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if server.is_finished() { - return Err(std::io::Error::other( - "Mint stopped before its readiness endpoint responded", - )); - } - if client - .get(format!("{issuer}/ready")) - .send() - .await - .is_ok_and(|response| response.status().is_success()) - { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await??; - - let (private, _, _) = key_pair(1); - let claims = assertion_claims_for_audience( - "health-ministry", - "jti-real-supervised-local", - &token_endpoint, - ); - let assertion = sign_assertion(&private, &claims); - let response = client - .post(&token_endpoint) - .form(&token_form(&assertion)) - .send() - .await?; - if !response.status().is_success() { - return Err(std::io::Error::other(format!( - "Mint token endpoint returned {}", - response.status() - )) - .into()); - } - let body = response.json::().await?; - let access_token = body["access_token"] - .as_str() - .ok_or_else(|| std::io::Error::other("token response has no access token"))?; - - let authentication = AuthenticationConfig { - kind: AuthenticationKind::OidcAccessToken, - issuer: issuer.clone(), - audiences: vec![EVIDENCE_AUDIENCE.to_owned()], - token_types: vec![AccessTokenType::AtJwt], - algorithms: vec![AccessTokenAlgorithm::ES256], - maximum_token_lifetime_seconds: 300, - revoked_key_ids: Vec::new(), - jwks_uri, - principal_claim: PRINCIPAL_CLAIM.to_owned(), - requester_tags_claim: REQUESTER_TAGS_CLAIM.to_owned(), - evidence_audience_claim: EVIDENCE_AUDIENCE_CLAIM.to_owned(), - grant_id_claim: GRANT_ID_CLAIM.to_owned(), - grant_authority_claim: GRANT_AUTHORITY_CLAIM.to_owned(), - allowed_clients: None, - required_scopes: None, - actor_claim: None, - }; - Authenticator::from_config(&authentication, AssuranceProfile::Local) - .authenticate(access_token) - .await - .map_err(Into::into) - } - .await; - - let _ = shutdown_tx.send(()); - tokio::time::timeout(Duration::from_secs(5), server) - .await - .expect("Mint shuts down within the grace period") - .expect("Mint server task joins") - .expect("Mint shuts down cleanly"); - - let context = proof.expect("the real local Mint and Evidence boundary is compatible"); - assert_eq!(context.principal(), "urn:example:health-ministry"); - assert_eq!(context.requester_tags(), ["health-ministry"]); - assert_eq!( - context.claim_path("iss"), - Some(&Value::String(issuer.clone())) - ); - assert_eq!( - context.claim_path("aud"), - Some(&Value::String(EVIDENCE_AUDIENCE.to_owned())) - ); - assert_eq!( - context.evidence_audience(), - "https://health-ministry.example.org" - ); - assert_eq!(context.grant_id(), Some("grant-7")); - assert_eq!(context.grant_authority(), Some("statute-12")); - assert_eq!(context.actor(), None); -} - -#[tokio::test] -async fn a_registered_client_cannot_borrow_another_clients_authority() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - // The health ministry's own key, but claiming to be the statistics office. - let (health_private, _, _) = key_pair(1); - let forged = sign_assertion( - &health_private, - &assertion_claims("statistics-office", "jti-forged"), - ); - let response = http.post("/token").form(&token_form(&forged)).await; - - assert_eq!(response.status_code(), 401); - // The public error never distinguishes an unknown client from a bad - // signature, so it cannot be used to enumerate the registry. - assert_eq!(response.json::(), json!({"error": "invalid_client"})); - - let unknown = sign_assertion( - &health_private, - &assertion_claims("no-such-client", "jti-x"), - ); - let response = http.post("/token").form(&token_form(&unknown)).await; - assert_eq!(response.status_code(), 401); - assert_eq!(response.json::(), json!({"error": "invalid_client"})); -} - -#[tokio::test] -async fn a_body_client_identifier_must_match_the_verified_assertion() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(1); - let assertion = sign_assertion( - &private, - &assertion_claims("health-ministry", "jti-mismatched-client-id"), - ); - let response = http - .post("/token") - .form(&token_form_with_client_id("statistics-office", &assertion)) - .await; - - assert_eq!(response.status_code(), 401); - assert_eq!(response.json::(), json!({"error": "invalid_client"})); -} - -#[tokio::test] -async fn an_assertion_cannot_be_presented_twice() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(2); - let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-once")); - - let first = http.post("/token").form(&token_form(&assertion)).await; - first.assert_status_ok(); - - let replayed = http.post("/token").form(&token_form(&assertion)).await; - assert_eq!(replayed.status_code(), 401); - assert_eq!(replayed.json::(), json!({"error": "invalid_client"})); - - deployment - .service - .reload_clients() - .expect("reload the same client registry"); - let replayed_after_reload = http.post("/token").form(&token_form(&assertion)).await; - assert_eq!(replayed_after_reload.status_code(), 401); - assert_eq!( - replayed_after_reload.json::(), - json!({"error": "invalid_client"}) - ); -} - -#[tokio::test] -async fn a_client_without_a_registered_grant_receives_no_grant_claims() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - let published = http.get("/.well-known/jwks.json").await.json::(); - - let (private, _, _) = key_pair(2); - let assertion = sign_assertion(&private, &assertion_claims("statistics-office", "jti-2")); - let response = http.post("/token").form(&token_form(&assertion)).await; - response.assert_status_ok(); - let access_token = response.json::()["access_token"] - .as_str() - .expect("the response carries an access token") - .to_owned(); - - let context = evidence_authenticator(&published) - .authenticate(&access_token) - .await - .expect("Evidence accepts the token"); - assert_eq!(context.principal(), "urn:example:statistics-office"); - assert_eq!(context.grant_id(), None); - assert_eq!(context.grant_authority(), None); -} - -#[tokio::test] -async fn the_published_metadata_points_at_the_endpoints_that_exist() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - for path in [ - "/.well-known/oauth-authorization-server", - "/.well-known/openid-configuration", - ] { - let metadata = http.get(path).await; - metadata.assert_status_ok(); - let document = metadata.json::(); - assert_eq!(document["issuer"], json!(ISSUER)); - assert_eq!(document["token_endpoint"], json!(ASSERTION_AUDIENCE)); - assert_eq!( - document["jwks_uri"], - json!(format!("{ISSUER}/.well-known/jwks.json")) - ); - assert_eq!( - document["token_endpoint_auth_methods_supported"], - json!([ - "private_key_jwt", - "client_secret_basic", - "client_secret_post" - ]) - ); - } - - // The metadata must describe routes this router actually serves. - http.get("/.well-known/jwks.json").await.assert_status_ok(); - let ready = http.get("/ready").await; - ready.assert_status_ok(); -} - -#[tokio::test] -async fn the_token_endpoint_refuses_anything_but_the_supported_grant() { - let deployment = deployment().await; - let http = TestServer::new(build_app(Arc::clone(&deployment.service))); - - let (private, _, _) = key_pair(1); - let assertion = sign_assertion(&private, &assertion_claims("health-ministry", "jti-grant")); - - let mut form = token_form(&assertion); - form[0].1 = "password".to_owned(); - let response = http.post("/token").form(&form).await; - assert_eq!(response.status_code(), 400); - assert_eq!( - response.json::(), - json!({"error": "unsupported_grant_type"}) - ); - - let mut form = token_form(&assertion); - form[1].1 = "urn:example:something-else".to_owned(); - let response = http.post("/token").form(&form).await; - assert_eq!(response.status_code(), 400); - assert_eq!( - response.json::(), - json!({"error": "invalid_request"}) - ); - - // A bearer-style secret is not an accepted authentication method. - let response = http - .post("/token") - .form(&vec![( - "grant_type".to_owned(), - GRANT_TYPE_CLIENT_CREDENTIALS.to_owned(), - )]) - .await; - assert_eq!(response.status_code(), 400); - assert_eq!( - response.json::(), - json!({"error": "invalid_request"}) - ); -} diff --git a/crates/registry-mint/tests/token_cli.rs b/crates/registry-mint/tests/token_cli.rs deleted file mode 100644 index bf98c11ff8..0000000000 --- a/crates/registry-mint/tests/token_cli.rs +++ /dev/null @@ -1,700 +0,0 @@ -//! `mint token` against a real `mint serve`, as two processes. -//! -//! The point of the subcommand is that it is an ordinary client: it proves who -//! it is and the endpoint decides. Testing it at the process boundary is what -//! shows that. It also pins the output contract the subcommand exists for, that -//! stdout carries the access token and nothing else, which no in-process test -//! of the builder could observe. - -use std::{ - fs, - io::ErrorKind, - net::{TcpListener, TcpStream}, - os::unix::fs::PermissionsExt, - path::{Path, PathBuf}, - process::{Child, Command, Output, Stdio}, - time::{Duration, Instant}, -}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use registry_platform_crypto::PublicJwk; -use serde_json::{json, Value}; - -const ACTOR: &str = "urn:example:agent:scheduler"; - -/// Deterministic Ed25519 material, so a test knows which identity signed what. -fn key_pair(seed: u8) -> (Value, Value) { - let seed_bytes = [seed; 32]; - let signing = ed25519_dalek::SigningKey::from_bytes(&seed_bytes); - let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes()); - let kid = format!("key-{seed}"); - ( - json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x}), - json!({"kty": "OKP", "crv": "Ed25519", "kid": kid, "alg": "EdDSA", "x": x, - "d": URL_SAFE_NO_PAD.encode(seed_bytes)}), - ) -} - -fn service_key_pair(seed: u8) -> (Value, Value) { - let scalar = [seed; 32]; - let signing = p256::ecdsa::SigningKey::from_slice(&scalar).expect("valid P-256 scalar"); - let encoded = signing.verifying_key().to_encoded_point(false); - let x = URL_SAFE_NO_PAD.encode(encoded.x().expect("uncompressed x")); - let y = URL_SAFE_NO_PAD.encode(encoded.y().expect("uncompressed y")); - let bare = PublicJwk::parse( - &json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "x":x, "y":y}).to_string(), - ) - .expect("public JWK parses"); - let kid = bare.jkt().expect("thumbprint computes"); - ( - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y}), - json!({"kty":"EC", "crv":"P-256", "alg":"ES256", "kid":kid, "x":x, "y":y, "d":URL_SAFE_NO_PAD.encode(scalar)}), - ) -} - -/// A running `mint serve`, killed when the test drops it however it ends. -struct Server { - _directory: tempfile::TempDir, - child: Child, - port: u16, - root: PathBuf, - config: PathBuf, - issuer: String, -} - -impl Drop for Server { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -impl Server { - fn token_url(&self) -> String { - format!("http://127.0.0.1:{}/token", self.port) - } - - fn caller_key(&self, client_id: &str) -> PathBuf { - self.root.join(format!("{client_id}.jwk")) - } - - fn assertion_audience(&self) -> String { - format!("{}/token", self.issuer) - } -} - -fn write_owner_only(path: &Path, contents: &str) { - fs::write(path, contents).expect("write secret"); - fs::set_permissions(path, fs::Permissions::from_mode(0o600)).expect("restrict secret"); -} - -/// Write a deployment, start the binary on it, and wait for the listener. -fn server() -> Server { - let directory = tempfile::tempdir().expect("temp dir"); - let root = directory.path().to_path_buf(); - fs::create_dir(root.join("secrets")).expect("create secrets directory"); - fs::create_dir(root.join("clients")).expect("create clients directory"); - fs::create_dir(root.join("public-keys")).expect("create public key directory"); - - let (signing_public, signing_private) = service_key_pair(9); - let public_file = format!( - "{}.jwk.json", - signing_public["kid"].as_str().expect("service key id") - ); - fs::write( - root.join("public-keys").join(&public_file), - signing_public.to_string(), - ) - .expect("write governed public key"); - write_owner_only( - &root.join("secrets/signing.jwk"), - &signing_private.to_string(), - ); - write_owner_only( - &root.join("secrets/audit-hmac-key"), - "0123456789abcdef0123456789abcdef", - ); - - // `listener.port: 0` would leave the test unable to find the port, so an - // ephemeral one is reserved and released. The window is a test-only risk. - let port = TcpListener::bind("127.0.0.1:0") - .expect("reserve a port") - .local_addr() - .expect("the reserved port") - .port(); - let issuer = format!("http://127.0.0.1:{port}"); - let assertion_audience = format!("{issuer}/token"); - - let (scheduler_public, scheduler_private) = key_pair(1); - write_owner_only(&root.join("scheduler.jwk"), &scheduler_private.to_string()); - fs::write( - root.join("clients/scheduler.yaml"), - format!( - "clientId: scheduler -principal: urn:example:principal:scheduler -evidenceAudience: https://scheduler.example.org -requesterTags: [scheduler] -keys: [{scheduler_public}] -delegation: - actors: [{ACTOR}] - subjectClaims: - given_name: identity.given_name - birth_date: identity.birth_date -" - ), - ) - .expect("write scheduler registration"); - - let (reporter_public, reporter_private) = key_pair(2); - write_owner_only(&root.join("reporter.jwk"), &reporter_private.to_string()); - fs::write( - root.join("clients/reporter.yaml"), - format!( - "clientId: reporter -principal: urn:example:principal:reporter -evidenceAudience: https://reporter.example.org -requesterTags: [reporter] -keys: [{reporter_public}] -" - ), - ) - .expect("write reporter registration"); - - let config = root.join("mint.yaml"); - fs::write( - &config, - format!( - "version: 1 -validationMode: supervised-local-development -issuer: {issuer} -listener: {{address: 127.0.0.1, port: {port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{public_file} - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing.jwk -secretProviders: - file: {{root: {}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence.example.org] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority - actor: evidence_actor -clientAssertion: - audience: {assertion_audience} - algorithms: [EdDSA] -clients: - directory: clients -", - root.join("secrets").display() - ), - ) - .expect("write config"); - - let child = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("serve") - .arg("--config") - .arg(&config) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("the mint binary starts"); - - let server = Server { - _directory: directory, - child, - port, - root, - config, - issuer, - }; - wait_for_listener(port); - server -} - -fn wait_for_listener(port: u16) { - let deadline = Instant::now() + Duration::from_secs(20); - loop { - match TcpStream::connect(("127.0.0.1", port)) { - Ok(_) => return, - Err(error) if error.kind() == ErrorKind::ConnectionRefused => { - assert!( - Instant::now() < deadline, - "the token endpoint never accepted" - ); - std::thread::sleep(Duration::from_millis(25)); - } - Err(error) => panic!("the token endpoint could not be reached: {error}"), - } - } -} - -fn mint_token(server: &Server, client_id: &str, extra: &[&str]) -> Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_mint")); - command - .arg("token") - .arg("--url") - .arg(server.token_url()) - // The endpoint is reached over loopback but its configured assertion - // audience is its public URL, which is the deployment shape behind a - // TLS terminator and the reason the flag exists. - .arg("--audience") - .arg(server.assertion_audience()) - .arg("--client-id") - .arg(client_id) - .arg("--key") - .arg(server.caller_key(client_id)) - .args(extra); - command.output().expect("the mint binary runs") -} - -fn claims_of(token: &str) -> Value { - let segments: Vec<&str> = token.split('.').collect(); - assert_eq!(segments.len(), 3, "an access token has three segments"); - serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1]).expect("base64url")) - .expect("claims parse") -} - -fn stdout_of(output: &Output) -> String { - assert!( - output.status.success(), - "the command failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout.clone()).expect("stdout is UTF-8") -} - -#[test] -fn the_subcommand_obtains_a_token_the_endpoint_agreed_to_issue() { - let server = server(); - let output = mint_token(&server, "reporter", &[]); - let stdout = stdout_of(&output); - - // Exactly one line, so `TOKEN=$(mint token ...)` is the whole usage. - assert_eq!(stdout.lines().count(), 1, "stdout was: {stdout:?}"); - let claims = claims_of(stdout.trim()); - - assert_eq!(claims["iss"], json!(server.issuer)); - assert_eq!(claims["sub"], json!("urn:example:principal:reporter")); - assert_eq!(claims["client_id"], json!("reporter")); - assert_eq!(claims["evidence_tags"], json!(["reporter"])); - assert_eq!( - claims["evidence_audience"], - json!("https://reporter.example.org") - ); - // The authority came from the registry, not from anything the caller sent. - assert!(claims.get("evidence_actor").is_none()); - - let verification = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("verify-audit") - .arg("--config") - .arg(&server.config) - .output() - .expect("the verifier runs"); - assert!(verification.status.success()); - let verification = String::from_utf8_lossy(&verification.stdout); - assert!(verification.contains("active-segment: not verified")); -} - -#[test] -fn the_configuration_check_runs_against_a_deployment_that_is_already_serving() { - // The whole point of `mint check` is to read a configuration before - // restarting the service that is running on it. One writer holds the audit - // chain for the life of the serving process, so a check that took the - // writer would report every live deployment as broken. - let server = server(); - let output = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("check") - .arg("--config") - .arg(&server.config) - .output() - .expect("the checker runs"); - - assert!( - output.status.success(), - "check failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn the_runtime_dependency_check_claims_the_audit_writer_before_startup() { - let server = server(); - let output = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("check") - .arg("--config") - .arg(&server.config) - .arg("--require-runtime-dependencies") - .output() - .expect("the checker runs"); - - assert!( - !output.status.success(), - "a full dependency preflight must not claim a live writer's audit chain" - ); - let diagnostics = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(!diagnostics.contains("0123456789abcdef")); -} - -/// Run the full dependency preflight and additionally require the configured -/// audit sink to resolve inside `root`, the path an operator declares persistent. -fn check_audit_under(server: &Server, root: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("check") - .arg("--config") - .arg(&server.config) - .arg("--require-runtime-dependencies") - .arg("--require-audit-under") - .arg(root) - .output() - .expect("the checker runs") -} - -/// Stop the staged deployment so the dependency preflight can claim the writer. -fn stopped_server() -> Server { - let mut server = server(); - server.child.kill().expect("stop the serving process"); - server.child.wait().expect("reap the serving process"); - server -} - -#[test] -fn the_dependency_check_accepts_an_audit_sink_inside_the_required_root() { - // `audit.path` is configured relative to the configuration file, so passing - // against the configuration directory is what proves Mint compared the - // destination its own configuration contract resolved. - let server = stopped_server(); - - let output = check_audit_under(&server, &server.root); - - assert!( - output.status.success(), - "check failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn the_check_proves_containment_for_a_configuration_named_by_a_relative_path() { - // An operator runs the preflight from the deployment directory with - // `--config mint.yaml`. The settings resolve against that directory - // wherever the process was started, so the proof compares the destination - // `serve` opens rather than a path relative to the working directory. - let server = stopped_server(); - let relative = server - .config - .strip_prefix(&server.root) - .expect("the configuration lives under the deployment root") - .to_path_buf(); - - let output = Command::new(env!("CARGO_BIN_EXE_mint")) - .current_dir(&server.root) - .arg("check") - .arg("--config") - .arg(&relative) - .arg("--require-runtime-dependencies") - .arg("--require-audit-under") - .arg(&server.root) - .output() - .expect("the checker runs"); - - assert!( - output.status.success(), - "check failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -#[test] -fn the_dependency_check_refuses_an_audit_sink_outside_the_required_root() { - let ephemeral = tempfile::tempdir().expect("temp dir"); - let server = stopped_server(); - - let output = check_audit_under(&server, ephemeral.path()); - - assert!( - !output.status.success(), - "an audit sink outside the declared root must fail closed" - ); - let diagnostics = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(diagnostics - .contains("the configured audit destination resolves outside the declared audit root")); - assert!(!diagnostics.contains(&server.root.join("audit/mint.jsonl").display().to_string())); - assert!(!diagnostics.contains("0123456789abcdef")); -} - -#[test] -fn the_dependency_check_refuses_an_audit_directory_symlinked_out_of_the_required_root() { - // The decoy: durable storage really is mounted at the declared root, and - // the configured path really does sit inside it, but the chain lands on - // storage that disappears with the container. The configured destination - // resolves under the configuration directory here, so Mint's own - // configuration contract refuses it before the declared root is compared; - // the containment flag's own lane is - // `the_dependency_check_refuses_an_audit_sink_outside_the_required_root`. - let ephemeral = tempfile::tempdir().expect("temp dir"); - let server = stopped_server(); - fs::remove_dir_all(server.root.join("audit")).expect("remove the staged audit directory"); - std::os::unix::fs::symlink(ephemeral.path(), server.root.join("audit")) - .expect("plant an escaping audit directory"); - - let output = check_audit_under(&server, &server.root); - - assert!( - !output.status.success(), - "a symlink must not carry the audit chain out of the declared root" - ); - let diagnostics = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(diagnostics.contains("audit path must resolve inside the configuration directory")); - assert!(!diagnostics.contains(&ephemeral.path().display().to_string())); -} - -#[test] -fn the_runtime_dependency_check_rejects_an_empty_client_registry() { - let mut server = server(); - server.child.kill().expect("stop the serving process"); - server.child.wait().expect("reap the serving process"); - fs::remove_file(server.root.join("clients/scheduler.yaml")) - .expect("remove scheduler registration"); - fs::remove_file(server.root.join("clients/reporter.yaml")) - .expect("remove reporter registration"); - - let output = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg("check") - .arg("--config") - .arg(&server.config) - .arg("--require-runtime-dependencies") - .output() - .expect("the checker runs"); - - assert!( - !output.status.success(), - "a Mint deployment with no registered clients must not pass runtime readiness" - ); -} - -#[test] -fn a_delegated_token_carries_the_actor_and_the_subject_from_the_registry_paths() { - let server = server(); - let subject = server.root.join("subject.json"); - fs::write( - &subject, - json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), - ) - .expect("write subject file"); - - let output = mint_token( - &server, - "scheduler", - &[ - "--actor", - ACTOR, - "--subject-file", - subject.to_str().expect("a UTF-8 path"), - ], - ); - let claims = claims_of(stdout_of(&output).trim()); - - assert_eq!(claims["evidence_actor"], json!(ACTOR)); - assert_eq!( - claims["identity"], - json!({"given_name": "Amara", "birth_date": "1998-04-02"}), - "the claim paths are the registry's, not the request's" - ); -} - -/// The subcommand authenticates; it does not decide. An actor the registration -/// does not permit must be refused by the endpoint, with no token printed. -#[test] -fn an_unregistered_actor_is_refused_by_the_endpoint() { - let server = server(); - let subject = server.root.join("other-subject.json"); - fs::write( - &subject, - json!({"given_name": "Amara", "birth_date": "1998-04-02"}).to_string(), - ) - .expect("write subject file"); - - let output = mint_token( - &server, - "scheduler", - &[ - "--actor", - "urn:example:agent:not-registered", - "--subject-file", - subject.to_str().expect("a UTF-8 path"), - ], - ); - - assert!(!output.status.success()); - assert!( - output.stdout.is_empty(), - "a refusal must print no token: {:?}", - String::from_utf8_lossy(&output.stdout) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("invalid_client"), - "the refusal should be reported: {stderr}" - ); -} - -/// A refused request must not put the signed assertion back on the terminal. -/// -/// `--url` and `--audience` are separate flags precisely so the assertion can be -/// audience-bound to the public endpoint while the request travels over -/// loopback. Point `--url` at the wrong host and the assertion, still valid at -/// the real Mint until it expires, is now that host's to echo. Repeating an -/// arbitrary body into stderr writes it to the operator's logs and scrollback -/// too, which is a second place to lose it from. -#[test] -fn a_refusal_does_not_echo_the_signed_assertion_back() { - let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind an echoing endpoint"); - let port = listener.local_addr().expect("a bound address").port(); - let echo = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept the token request"); - let mut request = Vec::new(); - // The form is small and the client closes after it, so reading to the - // content length is not worth a parser here. - let mut buffer = [0u8; 8192]; - loop { - let read = std::io::Read::read(&mut stream, &mut buffer).expect("read the request"); - request.extend_from_slice(&buffer[..read]); - if read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { - break; - } - } - let body = String::from_utf8_lossy(&request).into_owned(); - let payload = json!({ - "error": "invalid_request", - "error_description": "unrecognized request", - "received": body, - }) - .to_string(); - let response = format!( - "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}", - payload.len() - ); - std::io::Write::write_all(&mut stream, response.as_bytes()).expect("write the refusal"); - body - }); - - let server = server(); - let mut command = Command::new(env!("CARGO_BIN_EXE_mint")); - command - .arg("token") - .arg("--url") - .arg(format!("http://127.0.0.1:{port}/token")) - .arg("--audience") - .arg(server.assertion_audience()) - .arg("--client-id") - .arg("scheduler") - .arg("--key") - .arg(server.caller_key("scheduler")); - let output = command.output().expect("the mint binary runs"); - let received = echo.join().expect("the echoing endpoint finished"); - - let assertion = received - .split("client_assertion=") - .nth(1) - .expect("the endpoint received an assertion") - .split('&') - .next() - .expect("the assertion is a form value") - .to_owned(); - assert!( - assertion.len() > 64, - "the test needs a real assertion to look for: {assertion}" - ); - - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stderr.contains(&assertion), - "the refusal echoed the signed assertion back" - ); - // The status and the OAuth error stay, because that is what tells an - // operator which endpoint refused and why. - assert!( - stderr.contains("400") && stderr.contains("invalid_request"), - "the refusal should still name the status and the error: {stderr}" - ); -} - -/// A subject file that is not a flat object of scalars is a caller mistake with -/// an opaque server-side answer, so it is named locally instead. -#[test] -fn a_malformed_subject_file_is_refused_before_the_request() { - let server = server(); - let subject = server.root.join("nested-subject.json"); - fs::write( - &subject, - json!({"identity": {"given_name": "Amara"}}).to_string(), - ) - .expect("write subject file"); - - let output = mint_token( - &server, - "scheduler", - &[ - "--actor", - ACTOR, - "--subject-file", - subject.to_str().expect("a UTF-8 path"), - ], - ); - - assert!(!output.status.success()); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("must be a scalar value"), - "the mistake should be named: {stderr}" - ); -} - -/// A key file anyone else can read is a key that should be assumed leaked. -#[test] -fn a_group_readable_client_key_is_refused() { - let server = server(); - let key = server.caller_key("reporter"); - fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).expect("loosen the key file"); - - let output = mint_token(&server, "reporter", &[]); - - assert!(!output.status.success()); - assert!(output.stdout.is_empty()); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("client key could not be read"), - "the refusal should name the key file: {stderr}" - ); -} diff --git a/crates/registry-mint/tests/version_cli.rs b/crates/registry-mint/tests/version_cli.rs deleted file mode 100644 index b3fb374bd9..0000000000 --- a/crates/registry-mint/tests/version_cli.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::process::Command; - -fn version_output(flag: &str) -> String { - let output = Command::new(env!("CARGO_BIN_EXE_mint")) - .arg(flag) - .output() - .unwrap_or_else(|err| panic!("mint {flag} runs: {err}")); - - assert!( - output.status.success(), - "mint {flag} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).into_owned() -} - -#[test] -fn version_output_uses_user_facing_command_name() { - for flag in ["--version", "-V"] { - assert_eq!( - version_output(flag), - format!("mint {}\n", registry_platform_buildinfo::DISPLAY_VERSION) - ); - } -} - -#[test] -fn version_output_marks_a_build_that_is_not_a_release() { - let expected = if registry_platform_buildinfo::IS_RELEASE_BUILD { - env!("CARGO_PKG_VERSION").to_owned() - } else { - format!("{}-dev", env!("CARGO_PKG_VERSION")) - }; - - assert_eq!(version_output("--version"), format!("mint {expected}\n")); -} diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 05d393dff4..1c4409f21d 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -6,7 +6,7 @@ # while this record remains a draft. schema_version: registry.cli-reference-review/v3 status: current -last_reviewed: 2026-09-12 +last_reviewed: 2026-09-13 reviewed_source_version: "0.30.0" -reviewed_catalog_sha256: 2221f17eae216048d4cfaf7f66e3dcd68a250f6f6de4e24b177441819192b475 -reviewed_content_sha256: 95c11347f26ae7cee39faf0b53c9ed443af1bc0ff3f29cc267879d2dd325e5d6 +reviewed_catalog_sha256: 5024e1ba84323aec983e530918c7822571e2fecf2d0bc1f51af45817632a0296 +reviewed_content_sha256: 9e839054f084dad14a60879974b8d3ae57f650c22e2cf50d4ca92e1766907e7b From ec8c0596dc6359b616770bb0b1f5366101246c68 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:18:56 +0700 Subject: [PATCH 023/120] test(breg): prove fresh task status before automatic apply Signed-off-by: Jeremi Joslin --- .../tests/postgres_task_grants.rs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/registry-breg/tests/postgres_task_grants.rs b/crates/registry-breg/tests/postgres_task_grants.rs index 1db8ba26dd..18a25a9167 100644 --- a/crates/registry-breg/tests/postgres_task_grants.rs +++ b/crates/registry-breg/tests/postgres_task_grants.rs @@ -744,3 +744,111 @@ async fn task_http_to_postgres_preserves_original_authority_and_completed_receip } db.cleanup().await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn automatic_apply_rechecks_original_task_after_human_approval() { + let mut project: Value = serde_json::from_str(PROJECT).unwrap(); + project["entities"][2]["changeRequest"]["application"] = json!({"mode":"automatic"}); + let profiles = project["accessProfiles"].as_array_mut().unwrap(); + let apply_targets = profiles.iter().find(|p| p["id"] == "applier").unwrap()["permissions"][0] + ["applyTargets"] + .clone(); + let reviewer = profiles.iter_mut().find(|p| p["id"] == "reviewer").unwrap(); + reviewer["permissions"][0]["operations"] + .as_array_mut() + .unwrap() + .push(json!("apply_request")); + reviewer["permissions"][0]["applyTargets"] = apply_targets; + let registry = Arc::new( + compile_project( + &parse_project_json(&serde_json::to_vec(&project).unwrap()).unwrap(), + &[], + CompileProfile::Authoring, + ) + .unwrap(), + ); + let db = TestDatabase::create(8).await; + let identity = install(&db, ®istry).await; + let idp = MockIdp::start().await; + let status = Arc::new(Status::default()); + let app = app(&db, registry, identity, &idp, status.clone()); + let steward = human(&idp, "steward", "maintain"); + let reviewer = human(&idp, "reviewer", "review"); + let old = create( + &app, + "/v1/records/sites?accessProfile=steward", + &steward, + "old-site", + json!({"tenant":"tenant-a","name":"old"}), + ) + .await; + let new = create( + &app, + "/v1/records/sites?accessProfile=steward", + &steward, + "new-site", + json!({"tenant":"tenant-a","name":"new"}), + ) + .await; + let target = create( + &app, + "/v1/records/placements?accessProfile=steward", + &steward, + "placement", + json!({"tenant":"tenant-a","site":id(&old)}), + ) + .await; + let (grant, token) = agent(&idp, &status); + let draft = create(&app, "/v1/records/correction-requests?accessProfile=submitter", &token, + "draft", json!({"tenant":"tenant-a","placement":id(&target),"proposedSite":id(&new),"reason":"synthetic correction"})).await; + let record = id(&draft); + let submit = action( + &get(&app, &record, "submitter", &token).await, + "submit_request", + ); + assert_eq!( + perform(&app, &submit, &token, "submit").await.status, + StatusCode::OK + ); + let approve = action( + &get(&app, &record, "reviewer", &reviewer).await, + "approve_request", + ); + let before = counts(&db).await; + let before_calls = status.calls(); + // The approval check succeeds; revocation then precedes the automatic apply check. + *status.revoke_after_check.lock().unwrap() = Some(grant.clone()); + let refused = perform(&app, &approve, &reviewer, "automatic-refused").await; + assert_eq!( + refused.status, + StatusCode::PRECONDITION_FAILED, + "{}", + refused.body + ); + assert_eq!( + status.calls(), + before_calls + 2, + "automatic apply must reacquire original task status" + ); + assert_eq!( + counts(&db).await, + before, + "refused automatic apply rolls back approval and mutation together" + ); + // Restore the synthetic authority response to prove the same proposal can complete. + *status.revoke_after_check.lock().unwrap() = None; + status.revoked.lock().unwrap().remove(&grant); + let accepted = perform(&app, &approve, &reviewer, "automatic-accepted").await; + assert_eq!(accepted.status, StatusCode::OK, "{}", accepted.body); + let after = counts(&db).await; + assert_eq!( + after[4], + before[4] + 1, + "exactly one automatic application is recorded" + ); + assert_eq!( + after[6], before[6], + "the original task binding is retained unchanged" + ); + db.cleanup().await; +} From 9b8d1bbf29493c3b61c5d914ab7161f8d1ac69f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:33:12 +0700 Subject: [PATCH 024/120] docs(evidence): regenerate authentication key-path reference Signed-off-by: Jeremi Joslin --- .../reference/request-adapter/deployment-projects/CONFIG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 0d6f7178b7..adf859d601 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -1509,7 +1509,6 @@ authentication.allowedClients authentication.allowedClients[] authentication.audiences authentication.audiences[] -authentication.evidenceAudienceClaim authentication.claims authentication.claims.actorKind authentication.claims.approver @@ -1521,6 +1520,7 @@ authentication.claims.grantId authentication.claims.grantResource authentication.claims.grantSourceIssuer authentication.claims.purpose +authentication.evidenceAudienceClaim authentication.issuer authentication.jwksUri authentication.kind @@ -1535,6 +1535,7 @@ authentication.tokenTypes authentication.tokenTypes[] authorityProfiles authorityProfiles.* +authorityProfiles.*.grantSourceIssuer authorityProfiles.*.grants authorityProfiles.*.grants[] authorityProfiles.*.grants[].audienceFrom @@ -1552,7 +1553,6 @@ authorityProfiles.*.grants[].subjects[].valueClaims authorityProfiles.*.grants[].subjects[].valueClaims.* authorityProfiles.*.grants[].subjects[].valueOrigin authorityProfiles.*.kind -authorityProfiles.*.grantSourceIssuer authorityProfiles.*.requesterClients authorityProfiles.*.requesterClients[] authorityProfiles.*.requesterTags From 336b94712a4e34fb7b24e34d2068252c7ddd0d7a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:35:50 +0700 Subject: [PATCH 025/120] feat(tooling): acquire Casework approved task grants Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-bregctl/src/dev/mod.rs | 34 +++ crates/registry-bregctl/src/dev/tests.rs | 28 ++ .../src/task_grants/native_exchange_tests.rs | 69 ++++- crates/registry-caseworkctl/src/dev/mod.rs | 34 +++ crates/registry-caseworkctl/src/dev/tests.rs | 28 ++ crates/registry-evidencectl/src/dev.rs | 120 +++++---- crates/registry-evidencectl/src/lib.rs | 14 - .../tests/dev_lifecycle.rs | 15 +- crates/registry-thunderid-tooling/Cargo.toml | 2 + crates/registry-thunderid-tooling/README.md | 49 ++++ .../registry-thunderid-tooling/src/grant.rs | 240 ++++++++++++++++++ .../src/grant_file.rs | 225 ++++++++++++++++ .../src/grant_tests.rs | 150 +++++++++++ crates/registry-thunderid-tooling/src/lib.rs | 4 + 15 files changed, 937 insertions(+), 77 deletions(-) create mode 100644 crates/registry-thunderid-tooling/src/grant.rs create mode 100644 crates/registry-thunderid-tooling/src/grant_file.rs create mode 100644 crates/registry-thunderid-tooling/src/grant_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 6fc3621259..d3bd25639f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4495,6 +4495,7 @@ dependencies = [ "registry-platform-crypto", "registry-platform-httputil", "reqwest", + "rustix 1.1.4", "serde", "serde_json", "serde_norway", @@ -4502,6 +4503,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "url", + "zeroize", ] [[package]] diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index 7cb52d5c93..b899eba40a 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -88,6 +88,8 @@ enum DevAction { ExportClient(export_client::ExportClientArgs), /// Acquire a fresh local client token and report its private header-file path. Token(TokenArgs), + /// Exchange an existing Casework approval using an explicit configured issuer connection. + Grant(GrantArgs), /// Review or prepare a bounded lookup successor for a stopped retained registry. /// /// `evidencectl source add` drives this operation for an adopter, so the @@ -96,6 +98,21 @@ enum DevAction { PrepareSource(Box), } +#[derive(Debug, Args)] +struct GrantArgs { + /// Registered agent client ID in the owner-only connection file. + client: String, + /// Existing Casework-approved grant UUID; this command does not approve tasks. + #[arg(long)] + grant: String, + /// Owner-only task connection v1 file with the registered agent key and fixed target. + #[arg(long, value_name = "FILE")] + connection: PathBuf, + /// Existing project whose private directory receives the grant-specific header. + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, +} + #[derive(Debug, Args)] struct TokenArgs { /// Registered local client ID. @@ -311,11 +328,28 @@ pub fn run(args: DevArgs) -> Result { Some(DevAction::Start(args)) => start(args), Some(DevAction::ExportClient(args)) => export_client::run(args), Some(DevAction::Token(args)) => fresh_token(&args.project, &args.client), + Some(DevAction::Grant(args)) => approved_grant(args), Some(DevAction::PrepareSource(args)) => prepare_source::run(*args), None => start(args.start), } } +fn approved_grant(args: GrantArgs) -> Result { + let project = project(&args.project)?; + let output = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(registry_thunderid_tooling::grant_file::acquire_to_header( + &args.connection, + &project.join(".breg"), + &args.client, + &args.grant, + ))?; + Ok( + json!({"ok":true,"command":"dev grant","headerFile":output.header_file,"grantExpiresAt":output.grant_expires_at}), + ) +} + fn fresh_token(project_path: &Path, client: &str) -> Result { if !config::identifier(client) { bail!("a registered bounded local client ID is required"); diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index d0813ceca9..99afa0f3d3 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -1692,3 +1692,31 @@ fn candidate_issuer_image_is_immutable_and_retained() { .issuer_image .is_none()); } + +#[test] +fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { + let args = [ + "bregctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001", + "--connection", + "/tmp/task-connection.yaml", + "/tmp/project", + ]; + assert!(::try_parse_from(args).is_ok()); + assert!(::try_parse_from([ + "bregctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001" + ]) + .is_err()); + let mut arbitrary = args.to_vec(); + arbitrary.extend(["--purpose", "invented"]); + assert!(::try_parse_from(arbitrary).is_err()); +} diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 94af8b0a04..e39a74d5b9 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -524,7 +524,6 @@ async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_wr ) .await; assert_eq!(code, StatusCode::OK); - let assertion = issued["assertion"].as_str().unwrap(); let exchange = provider( &issuer.url(), "task-agent", @@ -532,7 +531,63 @@ async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_wr BREG_RESOURCE, "records:get", ); - let token = bearer(&exchange.exchange(assertion).await.unwrap()); + let agent_key_file = root.path().join("agent-key.json"); + std::fs::write(&agent_key_file, serde_json::to_vec(&json!({"kty":agent_key.kty,"alg":agent_key.alg,"kid":agent_key.kid,"n":agent_key.n,"e":agent_key.e,"d":agent_key.d,"p":agent_key.p,"q":agent_key.q,"dp":agent_key.dp,"dq":agent_key.dq,"qi":agent_key.qi})).unwrap()).unwrap(); + std::fs::set_permissions(&agent_key_file, std::fs::Permissions::from_mode(0o600)).unwrap(); + let connection_file = root.path().join("task-connection.yaml"); + std::fs::write(&connection_file, serde_json::to_vec(&json!({ + "version":1,"caseworkUrl":format!("http://127.0.0.1:{casework_port}"), + "tokenEndpoint":format!("{}/oauth2/token",issuer.url()), + "clientAssertionAudience":issuer.url(),"bootstrapResource":CASEWORK_RESOURCE, + "clients":{"task-agent":{"assertionKeyFile":agent_key_file,"resource":BREG_RESOURCE,"scopes":["records:get"]}} + })).unwrap()).unwrap(); + std::fs::set_permissions(&connection_file, std::fs::Permissions::from_mode(0o600)).unwrap(); + let private_output = root.path().join("product-private"); + use registry_thunderid_tooling::grant_file::acquire_to_header; + assert!(acquire_to_header( + &connection_file, + &private_output, + "unknown-client", + grant_id + ) + .await + .is_err()); + assert!(acquire_to_header( + &connection_file, + &private_output, + "task-agent", + &Uuid::new_v4().to_string() + ) + .await + .is_err()); + let acquired = acquire_to_header(&connection_file, &private_output, "task-agent", grant_id) + .await + .unwrap(); + assert_eq!( + acquired.grant_expires_at, + grant["expiresAt"].as_u64().unwrap() + ); + assert_eq!( + std::fs::metadata(&acquired.header_file) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let header = zeroize::Zeroizing::new(std::fs::read_to_string(&acquired.header_file).unwrap()); + let token = header + .trim() + .strip_prefix("Authorization: Bearer ") + .unwrap() + .to_string(); + // A fresh command rerun uses the retained connection and preserves the immutable deadline. + let acquired_again = + acquire_to_header(&connection_file, &private_output, "task-agent", grant_id) + .await + .unwrap(); + assert_eq!(acquired_again.header_file, acquired.header_file); + assert_eq!(acquired_again.grant_expires_at, acquired.grant_expires_at); let claims = payload(&token); assert_eq!(claims["registry_grant_id"], grant_id); assert_eq!(claims["registry_grant_source_issuer"], AUTHORITY); @@ -674,6 +729,16 @@ async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_wr ) .await; assert_eq!(code, StatusCode::FORBIDDEN); + let previous_header = std::fs::read(&acquired.header_file).unwrap(); + assert!( + acquire_to_header(&connection_file, &private_output, "task-agent", grant_id) + .await + .is_err() + ); + assert!( + previous_header == std::fs::read(&acquired.header_file).unwrap(), + "a refused grant command must not replace an existing header" + ); drop(app); db.cleanup().await; server.abort(); diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 7913ab5a58..b7fafb4227 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -113,6 +113,23 @@ enum DevAction { Events(EventsArgs), /// Write a fresh bearer header for a registered local teaching client. Token(TokenArgs), + /// Exchange an existing Casework approval using an explicit configured issuer connection. + Grant(GrantArgs), +} + +#[derive(Debug, Args)] +struct GrantArgs { + /// Registered agent client ID in the owner-only connection file. + client: String, + /// Existing Casework-approved grant UUID; this command does not approve tasks. + #[arg(long)] + grant: String, + /// Owner-only task connection v1 file with the registered agent key and fixed target. + #[arg(long, value_name = "FILE")] + connection: PathBuf, + /// Existing project whose private directory receives the grant-specific header. + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, } #[derive(Debug, Args)] @@ -313,11 +330,28 @@ pub(crate) fn run(args: DevArgs) -> Result { Some(DevAction::Stop(args)) => stop(&args.project, args.remove, args.docker_bin.as_deref()), Some(DevAction::Events(args)) => events(&args.project), Some(DevAction::Token(args)) => fresh_token(&args.project, &args.client), + Some(DevAction::Grant(args)) => approved_grant(args), Some(DevAction::Start(args)) => start(args), None => start(args.start), } } +fn approved_grant(args: GrantArgs) -> Result { + let project = project(&args.project)?; + let output = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(registry_thunderid_tooling::grant_file::acquire_to_header( + &args.connection, + &project.join(".casework"), + &args.client, + &args.grant, + ))?; + Ok( + json!({"ok":true,"command":"dev grant","headerFile":output.header_file,"grantExpiresAt":output.grant_expires_at}), + ) +} + fn fresh_token(project_path: &Path, client: &str) -> Result { if !config::identifier(client) { bail!("a registered bounded local client ID is required"); diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index e099e8ab0c..bca738835d 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -3430,3 +3430,31 @@ fn legacy_issuer_state_and_unsafe_token_clients_are_refused_without_effects() { .contains("bounded local client")); } } + +#[test] +fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { + let args = [ + "caseworkctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001", + "--connection", + "/tmp/task-connection.yaml", + "/tmp/project", + ]; + assert!(::try_parse_from(args).is_ok()); + assert!(::try_parse_from([ + "caseworkctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001" + ]) + .is_err()); + let mut arbitrary = args.to_vec(); + arbitrary.extend(["--purpose", "invented"]); + assert!(::try_parse_from(arbitrary).is_err()); +} diff --git a/crates/registry-evidencectl/src/dev.rs b/crates/registry-evidencectl/src/dev.rs index 89416eada1..a8835e98da 100644 --- a/crates/registry-evidencectl/src/dev.rs +++ b/crates/registry-evidencectl/src/dev.rs @@ -105,17 +105,6 @@ impl std::fmt::Display for RetiredMintDevelopment { impl std::error::Error for RetiredMintDevelopment {} -#[derive(Debug)] -pub(crate) struct TaskGrantAuthorityRequired; - -impl std::fmt::Display for TaskGrantAuthorityRequired { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("task grants must come from the configured Casework authority") - } -} - -impl std::error::Error for TaskGrantAuthorityRequired {} - #[derive(Debug, Args)] #[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)] pub struct DevArgs { @@ -177,16 +166,23 @@ enum DevAction { Clean(CleanArgs), /// Acquire a fresh local service token and report its private header-file path. Token(TokenArgs), - /// Retained only to explain the authority-backed task-grant workflow. - #[command(hide = true)] - Grant(RetiredGrantArgs), + /// Exchange an existing Casework approval using an explicit configured issuer connection. + Grant(GrantArgs), } #[derive(Debug, Args)] -#[command(trailing_var_arg = true)] -struct RetiredGrantArgs { - #[arg(value_name = "ARG", allow_hyphen_values = true)] - _arguments: Vec, +struct GrantArgs { + /// Registered agent client ID in the owner-only connection file. + client: String, + /// Existing Casework-approved grant UUID; this command does not approve tasks. + #[arg(long)] + grant: String, + /// Owner-only task connection v1 file with the registered agent key and fixed target. + #[arg(long, value_name = "FILE")] + connection: PathBuf, + /// Existing project whose private directory receives the grant-specific header. + #[arg(value_name = "PROJECT", default_value = ".")] + project: PathBuf, } #[derive(Debug, Args)] @@ -471,7 +467,7 @@ pub(crate) fn run_with_format(args: DevArgs, format: OutputFormat) -> Result fresh_token(&token.project, &token.client, format), - Some(DevAction::Grant(_)) => Err(TaskGrantAuthorityRequired.into()), + Some(DevAction::Grant(args)) => approved_grant(args, format), None => { if !args.detach { bail!("the local development lifecycle requires `evidencectl dev --detach`"); @@ -526,6 +522,30 @@ fn selected_ports( ) } +fn approved_grant(args: GrantArgs, format: OutputFormat) -> Result { + let project = fs::canonicalize(&args.project).context("the grant output project must exist")?; + let output = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(registry_thunderid_tooling::grant_file::acquire_to_header( + &args.connection, + &project.join(".evidence"), + &args.client, + &args.grant, + ))?; + match format { + OutputFormat::Human => println!( + "Wrote approved task authorization header to {}", + output.header_file.display() + ), + OutputFormat::Json => println!( + "{}", + json!({"operation":"dev-grant","status":"ready","headerFile":output.header_file,"grantExpiresAt":output.grant_expires_at}) + ), + } + Ok(ExitCode::SUCCESS) +} + fn fresh_token(project: &Path, client_id: &str, format: OutputFormat) -> Result { if !valid_local_identifier(client_id) { bail!("a registered bounded local client ID is required"); @@ -2610,38 +2630,36 @@ mod tests { let keys = root.path().join("keys"); generate_holder_key(&keys).expect("generate dev key"); - for name in ["holder"] { - let private_path = keys.join(format!("{name}-private.jwk")); - let private = registry_platform_crypto::PrivateJwk::parse( - &fs::read_to_string(&private_path).expect("private JWK"), - ) - .expect("private JWK parses"); - let public_path = keys.join("holder-public.jwk.json"); - let public = registry_platform_crypto::PublicJwk::parse( - &fs::read_to_string(&public_path).expect("public JWK"), - ) - .expect("public JWK parses"); - assert_eq!(private.kty, "EC"); - assert_eq!(private.crv.as_deref(), Some("P-256")); - assert_eq!(private.alg.as_deref(), Some("ES256")); - assert_eq!(private.kid, public.kid); - assert_eq!( - fs::metadata(private_path) - .expect("private JWK metadata") - .permissions() - .mode() - & 0o777, - PRIVATE_FILE_MODE - ); - assert_eq!( - fs::metadata(public_path) - .expect("public JWK metadata") - .permissions() - .mode() - & 0o777, - PRIVATE_FILE_MODE - ); - } + let private_path = keys.join("holder-private.jwk"); + let private = registry_platform_crypto::PrivateJwk::parse( + &fs::read_to_string(&private_path).expect("private JWK"), + ) + .expect("private JWK parses"); + let public_path = keys.join("holder-public.jwk.json"); + let public = registry_platform_crypto::PublicJwk::parse( + &fs::read_to_string(&public_path).expect("public JWK"), + ) + .expect("public JWK parses"); + assert_eq!(private.kty, "EC"); + assert_eq!(private.crv.as_deref(), Some("P-256")); + assert_eq!(private.alg.as_deref(), Some("ES256")); + assert_eq!(private.kid, public.kid); + assert_eq!( + fs::metadata(private_path) + .expect("private JWK metadata") + .permissions() + .mode() + & 0o777, + PRIVATE_FILE_MODE + ); + assert_eq!( + fs::metadata(public_path) + .expect("public JWK metadata") + .permissions() + .mode() + & 0o777, + PRIVATE_FILE_MODE + ); let before = fs::read(keys.join("holder-private.jwk")).expect("holder private JWK"); assert!(generate_holder_key(&keys).is_err()); assert_eq!( diff --git a/crates/registry-evidencectl/src/lib.rs b/crates/registry-evidencectl/src/lib.rs index fc347fa8ca..3c10f44d42 100644 --- a/crates/registry-evidencectl/src/lib.rs +++ b/crates/registry-evidencectl/src/lib.rs @@ -630,20 +630,6 @@ fn safe_dev_command(result: anyhow::Result) -> anyhow::Result() - .is_some() - { - return Err(SafeCliFailure { - operational: false, - code: "evidence.dev.grant-authority-required", - artifact: "task grant".to_owned(), - path: "$".to_owned(), - message: "evidencectl does not issue or forge task grants.".to_owned(), - suggested_action: "Approve the task through the configured Casework authority, then exchange its signed assertion at the configured issuer.".to_owned(), - } - .into()); - } safe_command( Err(error), "evidence.dev.failed", diff --git a/crates/registry-evidencectl/tests/dev_lifecycle.rs b/crates/registry-evidencectl/tests/dev_lifecycle.rs index c4d66b5bed..aa91a17563 100644 --- a/crates/registry-evidencectl/tests/dev_lifecycle.rs +++ b/crates/registry-evidencectl/tests/dev_lifecycle.rs @@ -162,19 +162,14 @@ fn retired_mint_flags_are_refused_before_private_state_is_created() { } #[test] -fn retired_grant_command_points_to_the_casework_authority() { +fn approved_grant_command_requires_connection_and_refuses_arbitrary_requirements() { let output = evidencectl() .args(["dev", "grant", "--requirement", "adult-status"]) .output() - .expect("retired grant command"); + .expect("approved grant command"); assert!(!output.status.success()); let error = String::from_utf8_lossy(&output.stderr); - assert!( - error.contains("evidence.dev.grant-authority-required"), - "{error}" - ); - assert!(error.contains("configured Casework authority"), "{error}"); - assert!(error.contains("signed assertion"), "{error}"); + assert!(error.contains("evidencectl.usage"), "{error}"); let help = evidencectl() .args(["dev", "--help"]) @@ -182,8 +177,8 @@ fn retired_grant_command_points_to_the_casework_authority() { .expect("dev help"); assert_success(&help, "dev help"); assert!( - !String::from_utf8_lossy(&help.stdout).contains("grant"), - "retired grant command must stay hidden" + String::from_utf8_lossy(&help.stdout).contains("grant"), + "approved grant acquisition must be discoverable" ); } diff --git a/crates/registry-thunderid-tooling/Cargo.toml b/crates/registry-thunderid-tooling/Cargo.toml index 12b5e2d51b..99faac176e 100644 --- a/crates/registry-thunderid-tooling/Cargo.toml +++ b/crates/registry-thunderid-tooling/Cargo.toml @@ -22,6 +22,7 @@ getrandom.workspace = true registry-platform-crypto.workspace = true registry-platform-httputil.workspace = true reqwest.workspace = true +rustix.workspace = true tokio.workspace = true serde.workspace = true serde_json.workspace = true @@ -29,6 +30,7 @@ serde_norway.workspace = true sha2.workspace = true thiserror.workspace = true url.workspace = true +zeroize.workspace = true [dev-dependencies] p256.workspace = true diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md index 8bcac625f8..213708bb86 100644 --- a/crates/registry-thunderid-tooling/README.md +++ b/crates/registry-thunderid-tooling/README.md @@ -109,3 +109,52 @@ flag. Neither operation removes retained files. ### Citizen delegation Citizen authorization-code delegation uses the reviewed native patch and rebuilt Gate in [the native extension](../../products/identity/thunderid/extension/README.md). `citizen::render` appends the closed provider, human type, consent flow and agent registration before `local::start`; use the build metadata's immutable candidate image ID. Copy the exact destination resource from its client export. This path always requires fresh purpose/field consent and admits no institutional grant namespace. + +## Acquire an approved task + +`bregctl`, `caseworkctl`, and `evidencectl` expose the same bounded command: + +```sh +bregctl dev grant task-agent --grant APPROVED-GRANT-UUID \ + --connection /absolute/private/task-connection.yaml ./project +``` + +The grant must already have been approved through the configured Casework API +or UI from a governed task template and current source-backed work item. This +command neither approves a task nor accepts purpose, selectors, operations or +other grant bounds. It does not start services or change runtime trust. Configure +Casework and the consuming resource with the intended shared issuer first; +independent default local-development issuer sessions do not automatically share +trust. + +Keep the connection file and existing registered agent key owner-only (0600). +Its closed v1 format is: + +```yaml +version: 1 +caseworkUrl: https://casework.example +# Stock ThunderID 1.0.1 expects its issuer URL as the client assertion audience. +tokenEndpoint: https://issuer.example/oauth2/token +clientAssertionAudience: https://issuer.example +bootstrapResource: urn:casework:example +clients: + task-agent: + assertionKeyFile: /absolute/private/task-agent.jwk + resource: urn:breg:example + scopes: [records:get] +``` + +The OAuth client must already be registered with that key and permitted to +exchange assertions from the configured Casework authority. Before approval, +its client-credentials permission is only `casework:grants:assert` at the +bootstrap resource. The requested resource and scopes above are fixed ceilings; +the real Casework assertion supplies immutable authority, subjects and task +bounds, and the issuer enforces the scope subset. + +Each invocation acquires a fresh bootstrap token, requests a short-lived signed +Casework assertion, and performs uncached RFC 8693 exchange. Only the final bearer +is written to `.breg/grants/-.header` (or `.casework/grants/` or +`.evidence/grants/`). The response reports that path and the original grant +expiry, never a token. A refusal leaves an existing header unchanged; the +resource's current authority checks still govern any attempted use. Run the +command again using the same connection after restarting configured services. diff --git a/crates/registry-thunderid-tooling/src/grant.rs b/crates/registry-thunderid-tooling/src/grant.rs new file mode 100644 index 0000000000..3baddfc13c --- /dev/null +++ b/crates/registry-thunderid-tooling/src/grant.rs @@ -0,0 +1,240 @@ +//! Acquire an existing Casework-approved task through standard OAuth exchange. +//! This helper never approves a task, signs an authority assertion, or accepts +//! selectors. The owning CLI supplies its retained connection and credential. +use std::collections::BTreeSet; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use registry_platform_crypto::PrivateJwk; +use registry_platform_httputil::{BearerToken, PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; +use serde::{Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use crate::ToolingError; + +const ASSERTION_SCOPE: &str = "casework:grants:assert"; +const MAXIMUM_RESPONSE_BYTES: usize = 64 * 1024; + +/// Explicit trusted connection, retained with the local session. Scopes and +/// resource are requested ceilings, never a source of grant authority. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GrantConnection { + pub casework_url: String, + pub token_endpoint: String, + pub client_assertion_audience: String, + pub bootstrap_resource: String, + pub resource: String, + pub scopes: Vec, +} + +fn refused(reason: &'static str) -> ToolingError { + ToolingError::GrantAcquisition { reason } +} + +fn endpoint(address: &str) -> Result { + let url = Url::parse(address).map_err(|_| refused("the configured endpoint is invalid"))?; + if address.len() > 2048 + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !(url.scheme() == "https" + || (url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")))) + || url.host_str().is_none() + { + return Err(refused( + "configured endpoints require HTTPS or explicit loopback HTTP", + )); + } + Ok(url) +} + +impl GrantConnection { + pub fn validate(&self) -> Result<(), ToolingError> { + endpoint(&self.casework_url)?; + endpoint(&self.token_endpoint)?; + if !registry_platform_httputil::valid_resource_uri(&self.client_assertion_audience) + || !registry_platform_httputil::valid_resource_uri(&self.bootstrap_resource) + || !registry_platform_httputil::valid_resource_uri(&self.resource) + || self.scopes.is_empty() + || self.scopes.len() > 32 + || self.scopes.iter().collect::>().len() != self.scopes.len() + || self.scopes.iter().any(|scope| { + scope.len() > 128 + || scope.contains('*') + || !registry_platform_httputil::valid_scope_token(scope) + }) + { + return Err(refused( + "the retained resource, assertion audience or scope ceiling is invalid", + )); + } + Ok(()) + } +} + +/// Deliberately lacks Debug and Serialize. The CLI may persist only the final +/// bearer in its owner-only grant-specific header, never the intermediate token. +pub struct GrantCredential { + pub token: BearerToken, + pub grant_expires_at: u64, +} + +#[derive(Deserialize, zeroize::ZeroizeOnDrop)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AssertionResponse { + assertion: String, + expires_at: u64, + grant_expires_at: u64, +} + +/// Fresh bootstrap -> real Casework assertion -> uncached RFC 8693 exchange. +/// The grant UUID is the sole task selection input. No human profile headers +/// are sent. Redirects, response bodies in errors and ambient proxying are off. +pub async fn acquire( + connection: &GrantConnection, + client_id: &str, + key: PrivateJwk, + grant_id: &str, +) -> Result { + connection.validate()?; + if !crate::description::valid_uuid(grant_id) { + return Err(refused("an approved grant UUID is required")); + } + let provider = |resource: &str, scopes: Vec| { + PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + endpoint(&connection.token_endpoint)?, + client_id, + key.clone(), + ) + .with_audience(connection.client_assertion_audience.clone()) + .with_resource(resource) + .with_scopes(scopes), + ) + .map_err(|_| refused("the retained client credential is unusable")) + }; + let bootstrap = provider(&connection.bootstrap_resource, vec![ASSERTION_SCOPE.into()])? + .bearer_token() + .await + .map_err(|_| refused("the configured issuer refused the bootstrap credential"))?; + let mut url = endpoint(&connection.casework_url)?; + url.path_segments_mut() + .map_err(|_| refused("the configured Casework endpoint is invalid"))? + .pop_if_empty() + .extend(["v1", "task-grants", grant_id, "assertion"]); + let http = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| refused("the assertion transport is unavailable"))?; + let mut response = http + .post(url) + .header( + reqwest::header::AUTHORIZATION, + bootstrap.authorization_header_value(), + ) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await + .map_err(|_| refused("the configured Casework authority is unavailable"))?; + if response.status() != reqwest::StatusCode::OK { + return Err(refused( + "Casework refused the grant; check its approval, client, expiry and revocation", + )); + } + registry_platform_httputil::validate_response_headers(response.headers()) + .map_err(|_| refused("the Casework assertion headers exceed their bounds"))?; + if response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .is_none_or(|value| !value.trim().eq_ignore_ascii_case("application/json")) + || response + .headers() + .contains_key(reqwest::header::CONTENT_ENCODING) + || response + .content_length() + .is_some_and(|length| length > MAXIMUM_RESPONSE_BYTES as u64) + { + return Err(refused( + "the Casework assertion response media type, encoding or size is invalid", + )); + } + let mut bytes = Zeroizing::new(Vec::new()); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| refused("the Casework assertion response was interrupted"))? + { + if bytes.len().saturating_add(chunk.len()) > MAXIMUM_RESPONSE_BYTES { + return Err(refused("the Casework assertion response exceeds its bound")); + } + bytes.extend_from_slice(&chunk); + } + let assertion: AssertionResponse = serde_json::from_slice(&bytes) + .map_err(|_| refused("the Casework assertion response is invalid"))?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| refused("the local clock is invalid"))? + .as_secs(); + if assertion.assertion.is_empty() + || assertion.assertion.len() > 32 * 1024 + || assertion.expires_at <= now + || assertion.expires_at > assertion.grant_expires_at + || assertion.expires_at > now.saturating_add(60) + { + return Err(refused( + "the Casework assertion has no current bounded lifetime", + )); + } + let token = provider(&connection.resource, connection.scopes.clone())? + .exchange(&assertion.assertion) + .await + .map_err(|_| refused("the configured issuer refused the approved grant exchange"))?; + Ok(GrantCredential { + token, + grant_expires_at: assertion.grant_expires_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn connection() -> GrantConnection { + GrantConnection { + casework_url: "http://127.0.0.1:8100".into(), + token_endpoint: "http://127.0.0.1:8091/oauth2/token".into(), + client_assertion_audience: "http://127.0.0.1:8091".into(), + bootstrap_resource: "urn:casework:local".into(), + resource: "urn:breg:local".into(), + scopes: vec!["records:get".into()], + } + } + #[test] + fn connection_refuses_ambiguous_or_unprotected_endpoints_and_scopes() { + assert!(connection().validate().is_ok()); + for address in [ + "http://example.test", + "https://user@example.test", + "https://example.test/?key=value", + "https://example.test/#fragment", + ] { + let mut candidate = connection(); + candidate.casework_url = address.into(); + assert!(candidate.validate().is_err()); + } + let mut candidate = connection(); + candidate.scopes.push(candidate.scopes[0].clone()); + assert!(candidate.validate().is_err()); + } +} + +#[cfg(test)] +#[path = "grant_tests.rs"] +mod wire_tests; diff --git a/crates/registry-thunderid-tooling/src/grant_file.rs b/crates/registry-thunderid-tooling/src/grant_file.rs new file mode 100644 index 0000000000..8904df3e6b --- /dev/null +++ b/crates/registry-thunderid-tooling/src/grant_file.rs @@ -0,0 +1,225 @@ +//! Closed operator connection file and owner-only final header output for CLIs. +//! There are no task-policy fields here: the UUID selects an existing approval. +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::grant::{acquire, GrantConnection}; +use crate::ToolingError; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Connections { + version: u8, + casework_url: String, + token_endpoint: String, + client_assertion_audience: String, + bootstrap_resource: String, + clients: BTreeMap, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Client { + assertion_key_file: PathBuf, + resource: String, + scopes: Vec, +} + +/// Non-secret CLI output. Header contents are never returned or printed. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GrantOutput { + pub header_file: PathBuf, + pub grant_expires_at: u64, +} +fn refuse(reason: &'static str) -> ToolingError { + ToolingError::GrantAcquisition { reason } +} +fn identifier(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} +fn metadata_valid(metadata: &fs::Metadata, directory: bool) -> bool { + metadata.uid() == rustix::process::geteuid().as_raw() + && metadata.permissions().mode() & 0o077 == 0 + && if directory { + metadata.is_dir() + } else { + metadata.is_file() && metadata.nlink() == 1 + } +} +fn read_private(path: &Path, maximum: usize) -> Result>, ToolingError> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(rustix::fs::OFlags::NOFOLLOW.bits() as i32) + .open(path) + .map_err(|_| refuse("the connection or credential file cannot be opened"))?; + if !file + .metadata() + .is_ok_and(|metadata| metadata_valid(&metadata, false)) + { + return Err(refuse( + "connection and credential files must be ordinary owner-only single-link files", + )); + } + let mut bytes = Zeroizing::new(Vec::new()); + (&mut file) + .take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| refuse("the connection or credential file cannot be read"))?; + if bytes.len() > maximum { + return Err(refuse( + "the connection or credential file exceeds its bound", + )); + } + Ok(bytes) +} +fn directory(path: &Path) -> Result<(), ToolingError> { + match fs::DirBuilder::new().mode(0o700).create(path) { + Ok(()) => (), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => (), + Err(_) => { + return Err(refuse( + "the private grant output directory cannot be created", + )) + } + } + if !fs::symlink_metadata(path).is_ok_and(|metadata| metadata_valid(&metadata, true)) { + return Err(refuse( + "grant output directories must be ordinary owner-only directories", + )); + } + Ok(()) +} + +/// Use an explicit retained operator connection. `private_root` is the owning +/// product's private directory, beneath an already canonical project directory. +/// The command neither starts services nor changes their configured trust. +pub async fn acquire_to_header( + connection_file: &Path, + private_root: &Path, + client_id: &str, + grant_id: &str, +) -> Result { + if !identifier(client_id) || !crate::description::valid_uuid(grant_id) { + return Err(refuse( + "a bounded registered client ID and approved grant UUID are required", + )); + } + let bytes = read_private(connection_file, 64 * 1024)?; + let connections: Connections = serde_norway::from_slice(&bytes).map_err(|_| { + refuse("the connection file must match the closed task connection v1 format") + })?; + if connections.version != 1 + || connections.clients.is_empty() + || connections.clients.len() > 32 + || connections.clients.keys().any(|id| !identifier(id)) + { + return Err(refuse( + "task connection v1 requires 1..32 bounded registered clients", + )); + } + let client = connections + .clients + .get(client_id) + .ok_or_else(|| refuse("the client is not registered in this connection file"))?; + if !client.assertion_key_file.is_absolute() { + return Err(refuse("the registered assertion key file must be absolute")); + } + let connection = GrantConnection { + casework_url: connections.casework_url, + token_endpoint: connections.token_endpoint, + client_assertion_audience: connections.client_assertion_audience, + bootstrap_resource: connections.bootstrap_resource, + resource: client.resource.clone(), + scopes: client.scopes.clone(), + }; + connection.validate()?; + let key_bytes = read_private(&client.assertion_key_file, 64 * 1024)?; + let key = registry_platform_crypto::PrivateJwk::parse( + std::str::from_utf8(&key_bytes) + .map_err(|_| refuse("the registered assertion key is invalid"))?, + ) + .map_err(|_| refuse("the registered assertion key is invalid"))?; + directory(private_root)?; + let output_root = private_root.join("grants"); + directory(&output_root)?; + let credential = acquire(&connection, client_id, key, grant_id).await?; + let header = credential.token.authorization_header_value(); + let mut bytes = Zeroizing::new(b"Authorization: ".to_vec()); + bytes.extend_from_slice(header.as_bytes()); + bytes.push(b'\n'); + let path = output_root.join(format!("{client_id}-{grant_id}.header")); + if let Ok(metadata) = fs::symlink_metadata(&path) { + if !metadata_valid(&metadata, false) { + return Err(refuse( + "the existing grant header is not an ordinary owner-only file", + )); + } + } + let temporary = output_root.join(format!( + ".grant-{}", + crate::container::random_urlsafe(16) + .map_err(|_| refuse("the private grant output nonce is unavailable"))? + )); + let write = || -> std::io::Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&temporary, &path)?; + File::open(&output_root)?.sync_all() + }; + if write().is_err() { + let _ = fs::remove_file(&temporary); + return Err(refuse("the private grant header could not be stored")); + } + Ok(GrantOutput { + header_file: path, + grant_expires_at: credential.grant_expires_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn credential_boundary_refuses_public_symlink_and_hardlinked_files() { + let root = std::env::temp_dir().join(format!( + "task-connection-{}", + crate::container::random_urlsafe(16).unwrap() + )); + directory(&root).unwrap(); + let path = root.join("connection"); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .unwrap(); + file.write_all(b"synthetic").unwrap(); + assert!(read_private(&path, 64).is_ok()); + assert!(read_private(&path, 2).is_err()); + let alias = root.join("alias"); + std::os::unix::fs::symlink(&path, &alias).unwrap(); + assert!(read_private(&alias, 64).is_err()); + std::fs::remove_file(&alias).unwrap(); + std::fs::hard_link(&path, &alias).unwrap(); + assert!(read_private(&path, 64).is_err()); + std::fs::remove_file(&alias).unwrap(); + std::fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_private(&path, 64).is_err()); + std::fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/crates/registry-thunderid-tooling/src/grant_tests.rs b/crates/registry-thunderid-tooling/src/grant_tests.rs new file mode 100644 index 0000000000..91cc0a5530 --- /dev/null +++ b/crates/registry-thunderid-tooling/src/grant_tests.rs @@ -0,0 +1,150 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::TcpListener; + +fn key() -> PrivateJwk { + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Es384, + ) + .unwrap(); + key.kid = Some("synthetic-client".into()); + key +} +fn serve(responses: Vec) -> (String, std::thread::JoinHandle>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + responses + .into_iter() + .map(|response| { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + while !bytes.ends_with(b"\r\n\r\n") { + assert!(bytes.len() < 32 * 1024); + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + let head = String::from_utf8(bytes.clone()).unwrap(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + assert!(length < 32 * 1024); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + bytes.extend(body); + stream.write_all(response.as_bytes()).unwrap(); + String::from_utf8(bytes).unwrap() + }) + .collect() + }); + (base, worker) +} +fn response(status: &str, headers: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", + body.len() + ) +} +fn token_response(exchange: bool) -> String { + let extra = if exchange { + ",\"issued_token_type\":\"urn:ietf:params:oauth:token-type:access_token\"" + } else { + "" + }; + response("200 OK", "Content-Type: application/json\r\n", &format!("{{\"access_token\":\"synthetic-token\",\"token_type\":\"Bearer\",\"expires_in\":300{extra}}}")) +} +fn connection(base: &str) -> GrantConnection { + GrantConnection { + casework_url: base.into(), + token_endpoint: format!("{base}/oauth2/token"), + client_assertion_audience: base.into(), + bootstrap_resource: "urn:casework:test".into(), + resource: "urn:breg:test".into(), + scopes: vec!["records:get".into()], + } +} +const GRANT: &str = "01970000-0000-7000-8000-000000000001"; + +#[tokio::test] +async fn wire_uses_only_agent_bootstrap_then_fixed_target_exchange() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let assertion = format!("{{\"assertion\":\"synthetic.assertion.signature\",\"expiresAt\":{},\"grantExpiresAt\":{}}}", now+60, now+900); + let (base, worker) = serve(vec![ + token_response(false), + response("200 OK", "Content-Type: application/json\r\n", &assertion), + token_response(true), + ]); + let credential = acquire(&connection(&base), "task-agent", key(), GRANT) + .await + .unwrap(); + assert_eq!(credential.grant_expires_at, now + 900); + let requests = worker.join().unwrap(); + assert!(requests[0].contains("grant_type=client_credentials")); + assert!(requests[0].contains("resource=urn%3Acasework%3Atest")); + assert!(requests[0].contains("scope=casework%3Agrants%3Aassert")); + assert!(requests[1].starts_with(&format!("POST /v1/task-grants/{GRANT}/assertion "))); + assert!(!requests[1].to_ascii_lowercase().contains("profile")); + assert!( + requests[2].contains("subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt") + ); + assert!(requests[2].contains("resource=urn%3Abreg%3Atest")); + assert!(requests[2].contains("scope=records%3Aget")); +} + +#[tokio::test] +async fn assertion_failures_are_redacted_and_never_reach_exchange() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expired = format!( + "{{\"assertion\":\"sensitive-canary\",\"expiresAt\":{},\"grantExpiresAt\":{}}}", + now - 1, + now + 900 + ); + let cases = [ + response( + "403 Forbidden", + "Content-Type: application/json\r\n", + "sensitive-canary", + ), + response( + "302 Found", + "Location: http://127.0.0.1:1/private\r\n", + "sensitive-canary", + ), + response("200 OK", "Content-Type: text/html\r\n", "sensitive-canary"), + response( + "200 OK", + "Content-Type: application/json\r\nContent-Encoding: gzip\r\n", + "sensitive-canary", + ), + response("200 OK", "Content-Type: application/json\r\n", &expired), + response( + "200 OK", + "Content-Type: application/json\r\n", + "{\"assertion\":\"sensitive-canary\"}", + ), + ]; + for assertion in cases { + let (base, worker) = serve(vec![token_response(false), assertion]); + let result = acquire(&connection(&base), "task-agent", key(), GRANT).await; + let Err(error) = result else { + panic!("invalid assertion was accepted") + }; + assert!(!error.to_string().contains("sensitive-canary")); + assert_eq!(worker.join().unwrap().len(), 2); + } +} diff --git a/crates/registry-thunderid-tooling/src/lib.rs b/crates/registry-thunderid-tooling/src/lib.rs index 987c0a9888..bc12a02ee7 100644 --- a/crates/registry-thunderid-tooling/src/lib.rs +++ b/crates/registry-thunderid-tooling/src/lib.rs @@ -29,6 +29,8 @@ pub mod bootstrap; pub mod citizen; pub mod container; pub mod description; +pub mod grant; +pub mod grant_file; pub mod issuer; pub mod local; mod local_session; @@ -58,6 +60,8 @@ pub enum ToolingError { CommandFailed { step: &'static str }, #[error("the issuer did not become reachable: {step}")] Unreachable { step: &'static str }, + #[error("the approved task grant could not be acquired: {reason}")] + GrantAcquisition { reason: &'static str }, #[error("the functional token check failed: {reason}")] TokenCheck { reason: &'static str }, #[error("an unexpected port occupant refused this session: {detail}")] From df1bbc706f71a0e15000ae67259fd5ab3c0e207f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:49:49 +0700 Subject: [PATCH 026/120] feat(casework): expose governed Evidence context in client bindings Signed-off-by: Jeremi Joslin --- .../__test__/task-grants.test.js | 4 ++-- crates/registry-casework-client-node/client.d.ts | 5 +++-- .../python/registry_casework_client/__init__.pyi | 7 ++++++- .../tests/python/test_task_grants.py | 4 ++-- crates/registry-stack-client-node/casework/client.d.ts | 5 +++-- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/registry-casework-client-node/__test__/task-grants.test.js b/crates/registry-casework-client-node/__test__/task-grants.test.js index d80e43cdf2..2687ea4674 100644 --- a/crates/registry-casework-client-node/__test__/task-grants.test.js +++ b/crates/registry-casework-client-node/__test__/task-grants.test.js @@ -5,8 +5,8 @@ const http=require('node:http'); const {CaseworkClient}=require('../client'); const item='00000000-0000-4000-8000-000000000001'; const grant='00000000-0000-4000-8000-000000000002'; -const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',scopes:['evidence:invoke'],purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; -const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,scopes:preview.scopes,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; +const preview={id:'verify-status',version:'1',label:'Verify status',agent:{issuer:'https://issuer.example',subject:'agent-one'},client:'agent-client',resource:'urn:evidence',scopes:['evidence:invoke'],evidenceContext:{requesterTags:['licensing-authority'],audience:'urn:licensing:review'},purpose:'verify-status',bounds:{type:'evidence',requirement:'status'},subjects:{person_reference:'synthetic-reference'},lifetimeSeconds:900}; +const view={id:grant,templateId:preview.id,templateVersion:preview.version,agent:preview.agent,client:preview.client,resource:preview.resource,scopes:preview.scopes,evidenceContext:preview.evidenceContext,purpose:preview.purpose,bounds:preview.bounds,expiresAt:2000000900,invalidated:false}; test('task grants preserve preview, approval revision/key and token-only machine calls',async(t)=>{ const requests=[]; const server=http.createServer((req,res)=>{let body='';req.on('data',b=>body+=b);req.on('end',()=>{requests.push({path:req.url,method:req.method,headers:req.headers,body});let value; diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 783f1586ae..f2dd219ab5 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -23,16 +23,17 @@ export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'dele export interface IssuerPrincipal { issuer: string; subject: string } export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string - bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger + evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string - bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean + evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean } export interface TaskGrantList { grants: ReadonlyArray } export interface TaskGrantRevocation { id: string; invalidated: boolean } diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 47c6c88c8b..bd63a23fe1 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -1,4 +1,4 @@ -from typing import Generic, Literal, TypeAlias, TypedDict, TypeVar +from typing import Generic, Literal, NotRequired, TypeAlias, TypedDict, TypeVar JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] @@ -32,6 +32,9 @@ class BregTaskBounds(TypedDict): type: Literal["breg"] permissions: list[TaskPermission] TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds +class EvidenceRequesterContext(TypedDict): + requesterTags: list[str] + audience: str class TaskApprovalRequest(TypedDict): templateId: str templateVersion: str @@ -43,6 +46,7 @@ class TaskTemplatePreview(TypedDict): client: str resource: str scopes: list[str] + evidenceContext: NotRequired[EvidenceRequesterContext] purpose: str bounds: TaskGrantBounds subjects: dict[str, str | int | bool] @@ -58,6 +62,7 @@ class TaskGrantView(TypedDict): client: str resource: str scopes: list[str] + evidenceContext: NotRequired[EvidenceRequesterContext] purpose: str bounds: TaskGrantBounds expiresAt: int diff --git a/crates/registry-casework-client-py/tests/python/test_task_grants.py b/crates/registry-casework-client-py/tests/python/test_task_grants.py index 755e465034..c65b41e00b 100644 --- a/crates/registry-casework-client-py/tests/python/test_task_grants.py +++ b/crates/registry-casework-client-py/tests/python/test_task_grants.py @@ -11,8 +11,8 @@ ITEM = "00000000-0000-4000-8000-000000000001" GRANT = "00000000-0000-4000-8000-000000000002" -PREVIEW = {"id": "verify-status", "version": "1", "label": "Verify status", "agent": {"issuer": "https://issuer.example", "subject": "agent-one"}, "client": "agent-client", "resource": "urn:evidence", "scopes": ["evidence:invoke"], "purpose": "verify-status", "bounds": {"type": "evidence", "requirement": "status"}, "subjects": {"person_reference": "synthetic-reference"}, "lifetimeSeconds": 900} -VIEW = {"id": GRANT, "templateId": PREVIEW["id"], "templateVersion": "1", **{k: PREVIEW[k] for k in ("agent", "client", "resource", "scopes", "purpose", "bounds")}, "expiresAt": 2000000900, "invalidated": False} +PREVIEW = {"id": "verify-status", "version": "1", "label": "Verify status", "agent": {"issuer": "https://issuer.example", "subject": "agent-one"}, "client": "agent-client", "resource": "urn:evidence", "scopes": ["evidence:invoke"], "evidenceContext": {"requesterTags": ["licensing-authority"], "audience": "urn:licensing:review"}, "purpose": "verify-status", "bounds": {"type": "evidence", "requirement": "status"}, "subjects": {"person_reference": "synthetic-reference"}, "lifetimeSeconds": 900} +VIEW = {"id": GRANT, "templateId": PREVIEW["id"], "templateVersion": "1", **{k: PREVIEW[k] for k in ("agent", "client", "resource", "scopes", "evidenceContext", "purpose", "bounds")}, "expiresAt": 2000000900, "invalidated": False} class TaskGrantTests(unittest.TestCase): def test_bounded_human_approval_and_token_only_machine_requests(self): diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 783f1586ae..f2dd219ab5 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -23,16 +23,17 @@ export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'dele export interface IssuerPrincipal { issuer: string; subject: string } export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { id: string; version: string; label: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string - bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger + evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; subjects: { readonly [key: string]: Exclude }; lifetimeSeconds: SafeInteger } export interface TaskTemplatePreviews { itemRevision: SafeInteger; templates: ReadonlyArray } /** Grant metadata deliberately excludes stored subject values. */ export interface TaskGrantView { id: string; templateId: string; templateVersion: string; agent: IssuerPrincipal; client: string; resource: string; scopes: ReadonlyArray; purpose: string - bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean + evidenceContext?: EvidenceRequesterContext; bounds: TaskGrantBounds; expiresAt: SafeInteger; invalidated: boolean } export interface TaskGrantList { grants: ReadonlyArray } export interface TaskGrantRevocation { id: string; invalidated: boolean } From 28344980dbff53a6d551a3dd21863223803c7d40 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:51:57 +0700 Subject: [PATCH 027/120] fix(casework): configure source reader OAuth token bounds Signed-off-by: Jeremi Joslin --- Cargo.lock | 3 + crates/registry-casework-breg/Cargo.toml | 2 + crates/registry-casework-breg/src/config.rs | 138 +++++++++++++++++- crates/registry-casework-core/Cargo.toml | 1 + crates/registry-casework/src/schema.rs | 1 + crates/registry-caseworkctl/src/source_add.rs | 2 +- products/casework/RUNTIME-CONFIG.md | 11 ++ .../professional-review/runtime.example.yaml | 5 +- .../generated/runtime/runtime.schema.json | 74 ++++++++++ 9 files changed, 230 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3bd25639f..bd3874b46c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3569,10 +3569,12 @@ name = "registry-casework-breg" version = "0.30.0" dependencies = [ "async-trait", + "base64", "registry-breg-client", "registry-casework-core", "registry-platform-config", "registry-platform-crypto", + "registry-platform-httputil", "schemars", "serde", "serde_json", @@ -3639,6 +3641,7 @@ dependencies = [ "chrono-tz", "jsonschema", "registry-platform-canonical-json", + "registry-platform-httputil", "serde", "serde_json", "serde_norway", diff --git a/crates/registry-casework-breg/Cargo.toml b/crates/registry-casework-breg/Cargo.toml index af4970b6e6..f4e81b48aa 100644 --- a/crates/registry-casework-breg/Cargo.toml +++ b/crates/registry-casework-breg/Cargo.toml @@ -21,6 +21,7 @@ registry-casework-core.workspace = true registry-breg-client.workspace = true registry-platform-config.workspace = true registry-platform-crypto.workspace = true +registry-platform-httputil.workspace = true schemars = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true @@ -29,6 +30,7 @@ url.workspace = true zeroize.workspace = true [dev-dependencies] +base64.workspace = true time.workspace = true tokio.workspace = true wiremock.workspace = true diff --git a/crates/registry-casework-breg/src/config.rs b/crates/registry-casework-breg/src/config.rs index 5a3b362c6e..82ff0786f6 100644 --- a/crates/registry-casework-breg/src/config.rs +++ b/crates/registry-casework-breg/src/config.rs @@ -54,6 +54,15 @@ pub struct BregBinding { pub base_url: String, pub reader_profile: String, pub token_endpoint: String, + /// Explicit OAuth client assertion audience; omission preserves endpoint audience. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_assertion_audience: Option, + /// Exact resource indicator required by the configured authorization server. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource: Option, + /// Explicit scopes for this source reader's service credential. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scopes: Option>, pub client_id_ref: String, pub client_assertion_key_ref: String, pub webhook_secret_ref: String, @@ -84,6 +93,9 @@ impl fmt::Debug for BregBinding { .field("base_url", &"[REDACTED]") .field("reader_profile", &self.reader_profile) .field("token_endpoint", &"[REDACTED]") + .field("client_assertion_audience", &"[REDACTED]") + .field("resource", &"[REDACTED]") + .field("scopes", &"[REDACTED]") .field("client_id_ref", &"[REDACTED]") .field("client_assertion_key_ref", &"[REDACTED]") .field("webhook_secret_ref", &"[REDACTED]") @@ -140,10 +152,7 @@ pub fn build_adapter( let request_timeout = Duration::from_millis(binding.request_timeout_milliseconds); let connect_timeout = Duration::from_millis(binding.connect_timeout_milliseconds); - let token_endpoint = parse_url(&binding.token_endpoint)?; - let mut token_config = PrivateKeyJwtConfig::new(token_endpoint, client_id, key) - .with_request_timeout(request_timeout) - .with_connect_timeout(connect_timeout); + let mut token_config = source_token_config(binding, &client_id, key)?; if let Some(trust) = &trust { token_config = token_config.with_trusted_root_certificates(trust.expose_secret().to_vec()); } @@ -177,6 +186,27 @@ pub fn build_adapter( ) } +fn source_token_config( + binding: &BregBinding, + client_id: &str, + key: PrivateJwk, +) -> Result { + validate_binding(binding)?; + let mut config = PrivateKeyJwtConfig::new(parse_url(&binding.token_endpoint)?, client_id, key) + .with_request_timeout(Duration::from_millis(binding.request_timeout_milliseconds)) + .with_connect_timeout(Duration::from_millis(binding.connect_timeout_milliseconds)); + if let Some(audience) = &binding.client_assertion_audience { + config = config.with_audience(audience); + } + if let Some(resource) = &binding.resource { + config = config.with_resource(resource); + } + if let Some(scopes) = &binding.scopes { + config = config.with_scopes(scopes.clone()); + } + Ok(config) +} + fn validate_binding(binding: &BregBinding) -> Result<(), SourceAdapterError> { if !valid_scalar(&binding.reader_profile, 512) || !valid_scalar(&binding.event_type, 512) @@ -189,6 +219,22 @@ fn validate_binding(binding: &BregBinding) -> Result<(), SourceAdapterError> { || binding.reconciliation_interval_milliseconds > MAXIMUM_RECONCILIATION_INTERVAL_MILLISECONDS || !valid_event_source(&binding.event_source) + || [&binding.client_assertion_audience, &binding.resource] + .iter() + .any(|value| { + value + .as_ref() + .is_some_and(|value| !registry_platform_httputil::valid_resource_uri(value)) + }) + || binding.scopes.as_ref().is_some_and(|scopes| { + scopes.is_empty() + || scopes.len() > registry_platform_httputil::MAXIMUM_REQUESTED_SCOPES + || scopes.iter().collect::>().len() != scopes.len() + || scopes.iter().any(|scope| { + scope.len() > registry_platform_httputil::MAXIMUM_REQUESTED_SCOPE_BYTES + || !registry_platform_httputil::valid_scope_token(scope) + }) + }) { return Err(SourceAdapterError::Invalid); } @@ -496,6 +542,15 @@ fn binding_generation( "connectTimeoutMilliseconds": binding.connect_timeout_milliseconds, "descriptionSha256": sha256_uri(description), }); + if let Some(audience) = &binding.client_assertion_audience { + identity["clientAssertionAudience"] = json!(audience); + } + if let Some(resource) = &binding.resource { + identity["resource"] = json!(resource); + } + if let Some(scopes) = &binding.scopes { + identity["scopes"] = json!(scopes); + } if let Some(display_reference) = source .requests .first() @@ -555,6 +610,9 @@ mod tests { base_url: "https://registry.example".into(), reader_profile: "casework-reader".into(), token_endpoint: "https://issuer.example/token".into(), + client_assertion_audience: None, + resource: None, + scopes: None, client_id_ref: "secret:file/client-id".into(), client_assertion_key_ref: "secret:file/client-key.jwk".into(), webhook_secret_ref: "secret:file/webhook".into(), @@ -784,7 +842,6 @@ mod tests { .unwrap(); assert_ne!(first, changed_reference); } - #[test] fn generation_ignores_reconciliation_cadence() { let source = source(); @@ -808,5 +865,76 @@ mod tests { .unwrap(); assert_eq!(first, changed_cadence); + + #[test] + fn source_token_authority_is_explicit_bounded_and_part_of_generation() { + let baseline = + binding_generation(&binding(), &source(), "reader", &description("correction")) + .unwrap(); + let mut configured = binding(); + configured.client_assertion_audience = Some("https://issuer.example".into()); + configured.resource = Some("urn:breg:example".into()); + configured.scopes = Some(vec!["casework:source-reader".into()]); + assert!(validate_binding(&configured).is_ok()); + assert_ne!( + baseline, + binding_generation(&configured, &source(), "reader", &description("correction")) + .unwrap() + ); + configured.scopes = Some(vec![]); + assert!(validate_binding(&configured).is_err()); + configured.scopes = Some(vec![ + "casework:source-reader".into(), + "casework:source-reader".into(), + ]); + assert!(validate_binding(&configured).is_err()); + configured.scopes = None; + configured.resource = Some(" not a resource".into()); + assert!(validate_binding(&configured).is_err()); + } + + #[tokio::test] + async fn source_reader_posts_configured_assertion_audience_resource_and_scopes() { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use registry_platform_httputil::TokenProvider; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth2/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token":"synthetic-reader-token","token_type":"Bearer","expires_in":300 + }))) + .expect(1) + .mount(&server) + .await; + let mut configured = binding(); + configured.token_endpoint = format!("{}/oauth2/token", server.uri()); + configured.client_assertion_audience = Some(server.uri()); + configured.resource = Some("urn:breg:configured".into()); + configured.scopes = Some(vec!["casework:source-reader".into()]); + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Es384, + ) + .unwrap(); + key.kid = Some("synthetic-reader-key".into()); + let provider = + PrivateKeyJwt::new(source_token_config(&configured, "casework-reader", key).unwrap()) + .unwrap(); + assert!(provider.bearer_token().await.is_ok()); + let requests = server.received_requests().await.unwrap(); + let fields: BTreeMap<_, _> = url::form_urlencoded::parse(&requests[0].body) + .into_owned() + .collect(); + assert_eq!(fields["resource"], "urn:breg:configured"); + assert_eq!(fields["scope"], "casework:source-reader"); + let assertion = fields["client_assertion"].split('.').nth(1).unwrap(); + let claims: Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(assertion).unwrap()).unwrap(); + assert_eq!(claims["aud"], server.uri()); + assert_eq!(claims["iss"], "casework-reader"); + assert_eq!(claims["sub"], "casework-reader"); } } diff --git a/crates/registry-casework-core/Cargo.toml b/crates/registry-casework-core/Cargo.toml index baeefbcdbc..37d14e3a96 100644 --- a/crates/registry-casework-core/Cargo.toml +++ b/crates/registry-casework-core/Cargo.toml @@ -12,6 +12,7 @@ publish = false workspace = true [dependencies] +registry-platform-httputil.workspace = true async-trait.workspace = true chrono = { workspace = true, features = ["serde"] } chrono-tz.workspace = true diff --git a/crates/registry-casework/src/schema.rs b/crates/registry-casework/src/schema.rs index cc823b65ed..2182558409 100644 --- a/crates/registry-casework/src/schema.rs +++ b/crates/registry-casework/src/schema.rs @@ -58,6 +58,7 @@ fn install_runtime_constraints(schema: &mut Value) { ("DatabaseConfig", "migrationUrlRef"), ("DatabaseConfig", "trustedRootCertificateRef"), ("AuditConfig", "hashKeyRef"), + ("TaskAuthorityConfig", "signingKeyRef"), ("BregBinding", "clientIdRef"), ("BregBinding", "clientAssertionKeyRef"), ("BregBinding", "webhookSecretRef"), diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 44aba68c8c..3a9e05adc0 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -90,7 +90,7 @@ pub(super) fn run(args: &SourceAddArgs) -> Result { "bregAuthoringPatch": {"event": event_patch, "accessProfile": reader_patch, "devClients": dev_clients_plan.patch}, "activation": "not_performed", "next": if args.apply { - json!(["Review the generated BReg runtime binding, provision its secret reference, and let the launcher activate each product through its normal path.", "Run caseworkctl doctor --runtime-config FILE after authenticated directory setup."]) + json!(["Review the generated BReg webhook binding and provision its secret reference. Configure the Casework source reader with the actual issuer tokenEndpoint, clientAssertionAudience, resource and scopes before activating through each product's normal path.", "Run caseworkctl doctor --runtime-config FILE after authenticated directory setup."]) } else { json!(["Review these exact local changes, then repeat source add with --apply."]) } diff --git a/products/casework/RUNTIME-CONFIG.md b/products/casework/RUNTIME-CONFIG.md index ff756dd598..f3d8cb70fd 100644 --- a/products/casework/RUNTIME-CONFIG.md +++ b/products/casework/RUNTIME-CONFIG.md @@ -61,6 +61,17 @@ invalidate displayed bindings or durable attempts. When a readback pass lasts longer than the interval, Casework skips missed ticks instead of replaying them back-to-back against the source. +For a BREG source, `tokenEndpoint` selects the reader's OAuth endpoint. +`clientAssertionAudience` explicitly overrides the JWT client assertion audience; +when omitted, it remains the token endpoint. `resource` is the exact RFC 8707 +resource indicator and `scopes` is the reader's nonempty bounded OAuth scope +list. Omission preserves an existing issuer's default behavior. Stock ThunderID +1.0.1 deployments must configure all three: its issuer URL as the assertion +audience, the exact BREG resource, and the registered reader scopes. Use the +actual deployment or `bregctl dev export-client` values, not a guessed resource +based on the project directory name. Changing these fields changes the source +binding generation and invalidates stale source-bound authority. + See the complete maintained [`runtime.example.yaml`](examples/professional-review/runtime.example.yaml) and the generated editor schema at diff --git a/products/casework/examples/professional-review/runtime.example.yaml b/products/casework/examples/professional-review/runtime.example.yaml index 3b5e396084..4e5a9697e7 100644 --- a/products/casework/examples/professional-review/runtime.example.yaml +++ b/products/casework/examples/professional-review/runtime.example.yaml @@ -43,7 +43,10 @@ sources: professional-register: baseUrl: https://registry.example.test readerProfile: casework-reader - tokenEndpoint: https://identity.example.test/realms/registry/token + tokenEndpoint: https://identity.example.test/oauth2/token + clientAssertionAudience: https://identity.example.test + resource: urn:breg:professional-licences + scopes: [casework:source-reader] clientIdRef: secret:file/breg-reader-client-id clientAssertionKeyRef: secret:file/breg-reader-key webhookSecretRef: secret:file/breg-casework-webhook diff --git a/products/casework/generated/runtime/runtime.schema.json b/products/casework/generated/runtime/runtime.schema.json index 2ef926972c..6005fd8af3 100644 --- a/products/casework/generated/runtime/runtime.schema.json +++ b/products/casework/generated/runtime/runtime.schema.json @@ -37,6 +37,13 @@ "baseUrl": { "type": "string" }, + "clientAssertionAudience": { + "description": "Explicit OAuth client assertion audience; omission preserves endpoint audience.", + "type": [ + "string", + "null" + ] + }, "clientAssertionKeyRef": { "pattern": "^secret:(?:env|file)/", "type": "string" @@ -74,6 +81,23 @@ "minimum": 0, "type": "integer" }, + "resource": { + "description": "Exact resource indicator required by the configured authorization server.", + "type": [ + "string", + "null" + ] + }, + "scopes": { + "description": "Explicit scopes for this source reader's service credential.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "tokenEndpoint": { "type": "string" }, @@ -191,6 +215,13 @@ "OidcConfig": { "additionalProperties": false, "properties": { + "allowedClients": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, "audience": { "type": "string" }, @@ -317,6 +348,39 @@ }, "type": "object" }, + "TaskAuthorityConfig": { + "additionalProperties": false, + "properties": { + "exchangeAudience": { + "type": "string" + }, + "id": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "signingKeyRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "statusClients": { + "additionalProperties": { + "type": "string" + }, + "description": "Service client IDs mapped to their one protected resource audience.", + "type": "object" + } + }, + "required": [ + "id", + "issuer", + "exchangeAudience", + "signingKeyRef", + "statusClients" + ], + "type": "object" + }, "TlsTermination": { "description": "Declares the trusted transport boundary for the runtime's plaintext HTTP listener.\n\nProduction listeners require operator-controlled upstream TLS termination.\nDirect plaintext is limited to the explicit loopback-only development mode.", "enum": [ @@ -461,6 +525,16 @@ "minLength": 1 }, "type": "object" + }, + "taskAuthority": { + "anyOf": [ + { + "$ref": "#/$defs/TaskAuthorityConfig" + }, + { + "type": "null" + } + ] } }, "required": [ From 090b6b7ffae68599230b3eeb3645f298303a0ee3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:52:04 +0700 Subject: [PATCH 028/120] docs: retire current Registry Mint guidance Signed-off-by: Jeremi Joslin --- AGENTS.md | 8 - README.md | 19 +- crates/registry-breg/install.sh | 10 +- crates/registry-casework/install.sh | 11 +- crates/registry-evidencectl/install.sh | 7 +- docs/site/astro.config.mjs | 14 +- docs/site/docs/style-guide.md | 8 +- .../images/registry-architecture-flow.svg | 15 +- .../public/images/registry-family-map.svg | 9 +- .../images/registry-trust-boundaries.svg | 12 +- docs/site/scripts/check-breg-tutorial.sh | 15 +- docs/site/scripts/check-casework-tutorial.sh | 18 +- docs/site/scripts/check-evidence-tutorials.sh | 18 +- docs/site/scripts/check-notary-surface.mjs | 2 +- .../scripts/check-notary-surface.test.mjs | 2 +- .../scripts/cli-reference-sidebar.test.mjs | 4 +- .../evidence-production-build-docs.test.mjs | 22 +- .../generate-breg-configuration.test.mjs | 2 +- docs/site/scripts/generate-cli-reference.mjs | 6 +- .../scripts/information-architecture.test.mjs | 12 +- .../replay-protection-contract.test.mjs | 66 +-- .../content/docs/configure/breg-access.mdx | 2 +- .../docs/configure/breg-change-control.mdx | 2 +- .../docs/configure/evidence-oid4vci.mdx | 12 +- .../src/content/docs/configure/evidence.mdx | 6 +- docs/site/src/content/docs/configure/mint.mdx | 380 --------------- .../site/src/content/docs/configure/relay.mdx | 8 +- .../configure/request-an-access-token.mdx | 290 +++--------- ...t-with-qgis-and-standard-oauth-clients.mdx | 177 ------- .../content/docs/explanation/architecture.mdx | 30 +- .../configuration-defined-registry.mdx | 11 +- .../esignet-authentication-over-breg.mdx | 56 ++- .../governed-registry-publication.mdx | 2 +- .../docs/explanation/how-casework-works.mdx | 2 +- .../docs/explanation/integration-patterns.mdx | 10 +- .../docs/explanation/known-limitations.mdx | 28 -- .../registry-modeling-patterns.mdx | 2 +- .../content/docs/explanation/threat-model.mdx | 99 +--- docs/site/src/content/docs/index.mdx | 4 +- .../content/docs/map/boundaries-and-map.mdx | 32 +- .../content/docs/operate/advanced/index.mdx | 1 - .../operate/advanced/inspect-and-diagnose.mdx | 23 +- .../advanced/rotate-credentials-and-trust.mdx | 75 +-- .../docs/operate/breg-requirements.mdx | 8 +- docs/site/src/content/docs/operate/breg.mdx | 13 +- .../src/content/docs/operate/casework.mdx | 5 +- .../docs/operate/evidence-requirements.mdx | 9 +- docs/site/src/content/docs/operate/relay.mdx | 5 +- .../retention-and-persistent-state.mdx | 33 +- .../content/docs/reference/api-stability.mdx | 31 +- .../src/content/docs/reference/apis/index.mdx | 8 +- .../src/content/docs/reference/breg-api.mdx | 2 +- .../docs/reference/environment-variables.mdx | 22 +- .../src/content/docs/reference/errors.mdx | 18 +- .../content/docs/reference/evidencectl.mdx | 3 +- .../src/content/docs/reference/glossary.mdx | 14 +- .../site/src/content/docs/reference/index.mdx | 1 - docs/site/src/content/docs/reference/mint.mdx | 445 ------------------ .../docs/security/hardening-checklist.mdx | 44 +- docs/site/src/content/docs/security/index.mdx | 12 +- docs/site/src/content/docs/spec/rs-arc-g.mdx | 29 +- .../src/content/docs/spec/rs-op-posture.mdx | 2 +- docs/site/src/content/docs/spec/rs-sec-g.mdx | 69 +-- docs/site/src/content/docs/spec/rs-terms.mdx | 11 +- .../content/docs/start/breg-quickstart.mdx | 10 +- docs/site/src/content/docs/start/casework.mdx | 12 +- .../docs/start/evidence-quickstart.mdx | 4 +- .../assert-a-role-bound-relationship.mdx | 6 +- .../build-and-deploy-evidence-project.mdx | 8 +- .../connect-an-institution-source.mdx | 2 +- .../control-who-can-request-evidence.mdx | 2 +- .../tutorials/deploy-evidence-from-breg.mdx | 3 +- .../src/content/docs/tutorials/first-breg.mdx | 60 ++- .../content/docs/tutorials/first-casework.mdx | 118 ++--- ...evidence-candidate-with-docker-compose.mdx | 5 +- ...dence-access-tokens-with-registry-mint.mdx | 142 ------ .../tutorials/issue-fhir-evidence-as-vcs.mdx | 2 +- .../move-evidence-to-production-signing.mdx | 68 +-- .../query-a-spatial-registry-from-qgis.mdx | 347 -------------- .../request-evidence-from-an-application.mdx | 2 +- .../tutorials/review-registry-changes.mdx | 2 +- docs/site/src/data/projects.yaml | 21 +- 82 files changed, 427 insertions(+), 2703 deletions(-) delete mode 100644 docs/site/src/content/docs/configure/mint.mdx delete mode 100644 docs/site/src/content/docs/configure/use-mint-with-qgis-and-standard-oauth-clients.mdx delete mode 100644 docs/site/src/content/docs/reference/mint.mdx delete mode 100644 docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx delete mode 100644 docs/site/src/content/docs/tutorials/query-a-spatial-registry-from-qgis.mdx diff --git a/AGENTS.md b/AGENTS.md index 8db7516be9..5d267018a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,13 +46,6 @@ Registry Manifest describes sources portably; Relay is its consumer in code and `registry-platform-*` crates are shared primitives. `relayctl` is Relay adopter tooling; `registry-evidencectl` is Evidence adopter tooling. -Registry Mint is a supporting service, not a runtime product of its own: it -issues the access tokens a resource server such as Evidence or Base Registry -Engine verifies, for deployments with no identity provider. The dependency runs -one way only in production: no Evidence crate depends on Mint at runtime. Mint's -tests drive Evidence's authenticator, and Evidence test code may drive a real -Mint instance to prove a client against a real authorization server. - `registry-evidence-oid4vci` is a supporting service in the same sense, not a runtime product of its own: it delivers Evidence credentials to a wallet over OID4VCI 1.0 Final, the wallet-facing protocol Evidence deliberately refuses to @@ -96,7 +89,6 @@ The dependency runs one way only in production: no Evidence crate depends on | `crates/registry-evidence-client-py` | Python binding for `registry-evidence-client`, via PyO3 | | `crates/registry-evidencectl` | Evidence adopter tooling (`evidencectl`): key material, incomplete OpenAPI authoring workspaces, fixture runs for complete projects | | `crates/registry-evidence-authoring` | The authoring form: the single implementation of the model an adopter writes and the checks it must satisfy, shared by adopter tooling | -| `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 | diff --git a/README.md b/README.md index 81501b45b8..25fc39706a 100644 --- a/README.md +++ b/README.md @@ -69,24 +69,23 @@ its own deployment contract and adopter tooling, and each one is optional. Evidence Gateway can use a Base Registry Engine or Registry Relay API as one of its fixed sources, and keeps its own authorization either way. The stack also -includes Registry Mint for short-lived access tokens, Registry Platform shared -primitives, the `bregctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter -tools, unified Node.js and Python clients, and release tooling for validating -the public source model. +includes Registry Platform shared primitives, the `bregctl`, `caseworkctl`, +`relayctl`, `evidencectl`, and `discoveryctl` adopter tools, unified Node.js and +Python clients, and release tooling for validating the public source model. ### Install a released build ```bash -# Base Registry Engine: breg, bregctl, and mint +# Base Registry Engine: breg and bregctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash -# Registry Casework: casework, caseworkctl, and mint +# Registry Casework: casework and caseworkctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash # Registry Relay: relay and relayctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash -# Evidence Gateway: evidence, evidencectl, mint, and evidence-oid4vci +# Evidence Gateway: evidence, evidencectl, and evidence-oid4vci curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/evidencectl-install.sh | bash ``` @@ -98,7 +97,7 @@ installer: download `discovery--linux-amd64` or `registry-manifest--linux-amd64` from the [release page](https://github.com/registrystack/registry-stack/releases) and check it against the release checksum chain. Container images for `breg`, -`casework`, `relay`, `evidence`, `mint`, and `discovery` are published as +`casework`, `relay`, `evidence`, and `discovery` are published as `ghcr.io/registrystack/:`. Which platforms each artifact supports, and what is not supported, is recorded in [known limitations](https://docs.registrystack.org/dev/explanation/known-limitations/#platform-support). @@ -111,7 +110,6 @@ flowchart LR breg["Base Registry Engine
hold and update records"] evidence["Evidence Gateway
minimum-disclosure assertions"] discovery["Registry Discovery
index published providers"] - mint["Registry Mint
issue short-lived tokens"] caller["Approved service or verifier"] source --> relay @@ -122,7 +120,6 @@ flowchart LR relay -. protected fixed request .-> evidence breg -. protected fixed request .-> evidence evidence -. signed assertion .-> caller - mint -. access token .-> caller relay -. advertisement .-> discovery evidence -. advertisement .-> discovery discovery -. index lookup .-> caller @@ -132,7 +129,7 @@ flowchart LR - `crates/`: Rust crates and runnable binaries for Base Registry Engine, Registry Relay, Evidence Gateway, Registry Casework, Registry Discovery, - Registry Manifest, Registry Mint, Registry Platform, and the `bregctl`, + Registry Manifest, Registry Platform, and the `bregctl`, `caseworkctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter tools. Base Registry Engine lives in `crates/registry-breg` with one `breg` binary, Evidence Gateway in `crates/registry-evidence` with one `evidence` diff --git a/crates/registry-breg/install.sh b/crates/registry-breg/install.sh index 938d194802..887cb0e74c 100755 --- a/crates/registry-breg/install.sh +++ b/crates/registry-breg/install.sh @@ -2,7 +2,7 @@ set -euo pipefail repo="registrystack/registry-stack" -binaries=(breg bregctl mint) +binaries=(breg bregctl) # Publication packaging replaces this empty value with the asset's canonical tag. default_version="" script_name="${BASH_SOURCE[0]:-}" @@ -30,17 +30,14 @@ asset_dir="${BREG_ASSET_DIR:-}" usage() { cat </casework--install.sh | bash The installer verifies every downloaded release asset against the release's SHA256SUMS before anything reaches the install directory, and installs the -three binaries together or not at all. It does not verify release authenticity. For +two binaries together or not at all. It does not verify release authenticity. For a higher-assurance installation, follow the release verification guide for the pinned tag, then rerun with CASEWORK_ASSET_DIR set to the verified directory: @@ -436,7 +432,6 @@ Try it: caseworkctl init --help caseworkctl check --help casework --help - mint --help EOF diff --git a/crates/registry-evidencectl/install.sh b/crates/registry-evidencectl/install.sh index 9eb40c8586..9d46578478 100644 --- a/crates/registry-evidencectl/install.sh +++ b/crates/registry-evidencectl/install.sh @@ -2,7 +2,7 @@ set -euo pipefail repo="registrystack/registry-stack" -binaries=(evidence evidencectl mint evidence-oid4vci) +binaries=(evidence evidencectl evidence-oid4vci) # Publication packaging replaces this empty value with the asset's canonical tag. default_version="" script_name="${BASH_SOURCE[0]:-}" @@ -31,15 +31,14 @@ asset_dir="${EVIDENCECTL_ASSET_DIR:-}" usage() { cat < Registry architecture flow - Registry Platform provides shared primitives to every runtime product. Registry Manifest produces portable metadata contracts describing what a registry exposes. Base Registry Engine compiles a declared registry project into a PostgreSQL-backed writable registry with a REST API, revision history, and an audit journal. Registry Relay independently compiles a reviewed registry contract and serves protected read-only operations over one local read-only SQLite source; it does not read a Registry Manifest document. Evidence Gateway independently answers one bounded question about one subject from its own configured authoritative sources, reached over a fixed HTTP JSON request or one reviewed SQL statement against a read-only SQLite extract, and returns a signed assertion carrying the answer and not the record. A Base Registry Engine route or a Relay-protected API can be one of those fixed HTTP sources, and Evidence Gateway inherits neither one's authorization. Registry Discovery serves one immutable index built offline from approved provider descriptions and leaves endpoint trust to the relying application. Registry Mint is a supporting service that issues the short-lived access tokens a resource server such as Evidence Gateway or Base Registry Engine verifies, for deployments with no identity provider. The external Solmara Lab adopter demo provides synthetic registry data. + Registry Platform provides shared primitives to every runtime product. Registry Manifest produces portable metadata contracts describing what a registry exposes. Base Registry Engine compiles a declared registry project into a PostgreSQL-backed writable registry with a REST API, revision history, and an audit journal. Registry Relay independently compiles a reviewed registry contract and serves protected read-only operations over one local read-only SQLite source; it does not read a Registry Manifest document. Evidence Gateway independently answers one bounded question about one subject from its own configured authoritative sources, reached over a fixed HTTP JSON request or one reviewed SQL statement against a read-only SQLite extract, and returns a signed assertion carrying the answer and not the record. A Base Registry Engine route or a Relay-protected API can be one of those fixed HTTP sources, and Evidence Gateway inherits neither one's authorization. Registry Discovery serves one immutable index built offline from approved provider descriptions and leaves endpoint trust to the relying application. The external Solmara Lab adopter demo provides synthetic registry data. SHARED PRIMITIVES @@ -106,15 +106,4 @@ can be one fixed Evidence source. - - SUPPORTING SERVICE - - - Registry Mint - registry-mint - - Issues the short-lived access tokens Evidence Gateway and - Base Registry Engine verify, for deployments with no identity provider. - diff --git a/docs/site/public/images/registry-family-map.svg b/docs/site/public/images/registry-family-map.svg index bc2ed82a38..cff47703e3 100644 --- a/docs/site/public/images/registry-family-map.svg +++ b/docs/site/public/images/registry-family-map.svg @@ -2,7 +2,7 @@ font-family="'Public Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif" text-rendering="geometricPrecision"> Registry family map - The four formal Registry Stack products, arranged left to right by structural role, followed by Registry Mint as a supporting service and the external Solmara Lab adopter demo. Registry Platform provides shared primitives; Registry Manifest defines portable metadata contracts; Registry Relay and Evidence Gateway are independent runtime services, Relay serving protected read-only APIs over sensitive registry sources and Evidence Gateway answering one bounded question about one subject with a signed assertion. Registry Mint issues the short-lived access tokens Evidence Gateway verifies, for deployments with no identity provider. Solmara Lab provides synthetic registry data and demo configuration. + The formal Registry Stack products, arranged by structural role, followed by the external Solmara Lab adopter demo. Registry Platform provides shared primitives; Registry Manifest defines portable metadata contracts; Registry Relay and Evidence Gateway are independent runtime services, Relay serving protected read-only APIs over sensitive registry sources and Evidence Gateway answering one bounded question about one subject with a signed assertion. Solmara Lab provides synthetic registry data and demo configuration. @@ -69,30 +69,23 @@ - SUPPORTING SERVICE EXTERNAL DEMO - - Registry Mint Solmara Lab - registry-mint solmara-lab - Issues the short-lived access tokens - Evidence Gateway verifies. - External adopter demo with synthetic registry data and configuration. diff --git a/docs/site/public/images/registry-trust-boundaries.svg b/docs/site/public/images/registry-trust-boundaries.svg index e4b46d40f5..6fc263a09f 100644 --- a/docs/site/public/images/registry-trust-boundaries.svg +++ b/docs/site/public/images/registry-trust-boundaries.svg @@ -2,7 +2,7 @@ font-family="'Public Sans', system-ui, -apple-system, BlinkMacSystemFont, sans-serif" text-rendering="geometricPrecision"> Registry Stack trust boundaries - One request path runs downward from Registry Mint, which issues a short-lived access token, to the caller, to Evidence Gateway, and to Evidence Gateway's configured authoritative source, which it reaches over a fixed HTTP request or a read-only SQLite extract. Registry Relay has a separate protected-read path to its own registry sources. The dashed rule above Evidence Gateway marks the authenticated service edge; the dashed rule below it marks the source edge, which is authenticated only when the source is HTTP, since a statement source has no origin, no credential, and no network hop. Caller to Evidence Gateway carries a bearer token, a declared purpose, a named requirement revision, and selector values, and returns the answer rather than the record. Evidence Gateway source access is one fixed source request per acquisition stage, fixed by trusted configuration and executed only by the core: an HTTP source fixes origin, method, path, headers, authentication, TLS, projection, and redirect and proxy denial, and a statement source fixes one reviewed SQL statement over a local read-only extract with no origin, no credential, and no network hop. Lookup collapses to match, no match, or ambiguous, and raw responses are never logged. Registry Mint to Evidence Gateway runs one way: Evidence Gateway verifies tokens as an ordinary resource server and does not depend on Registry Mint. A separate lane runs operator to signing key material to Evidence Gateway to the published key set to the verifier, which pins that key set out of band. The footnote lists the unauthenticated operational and discovery routes at the Evidence Gateway edge. + One request path runs downward from the deployment's configured OAuth issuer to the caller, to Evidence Gateway, and to Evidence Gateway's configured authoritative source, which it reaches over a fixed HTTP request or a read-only SQLite extract. Registry Relay has a separate protected-read path to its own registry sources. The dashed rule above Evidence Gateway marks the authenticated service edge; the dashed rule below it marks the source edge, which is authenticated only when the source is HTTP, since a statement source has no origin, no credential, and no network hop. Caller to Evidence Gateway carries a bearer token, a declared purpose, a named requirement revision, and selector values, and returns the answer rather than the record. Evidence Gateway source access is one fixed source request per acquisition stage, fixed by trusted configuration and executed only by the core: an HTTP source fixes origin, method, path, headers, authentication, TLS, projection, and redirect and proxy denial, and a statement source fixes one reviewed SQL statement over a local read-only extract with no origin, no credential, and no network hop. Lookup collapses to match, no match, or ambiguous, and raw responses are never logged. Evidence Gateway verifies tokens against its configured issuer and public key set. A separate lane runs operator to signing key material to Evidence Gateway to the published key set to the verifier, which pins that key set out of band. The footnote lists the unauthenticated operational and discovery routes at the Evidence Gateway edge. TRUST BOUNDARIES @@ -16,7 +16,7 @@ - Registry Mint + OAuth issuer Caller Evidence Gateway Authoritative source @@ -25,7 +25,7 @@ - registry-mint + deployment configured registry-evidence registry-relay @@ -67,7 +67,7 @@ - REGISTRY MINT TO EVIDENCE GATEWAY + OAUTH ISSUER TO EVIDENCE GATEWAY CALLER TO EVIDENCE GATEWAY THE SERVICE EDGE (AUTHENTICATION) EVIDENCE TO ITS FIXED SOURCES @@ -76,9 +76,9 @@ - One way. Registry Mint issues the token; Evidence Gateway verifies it as + The configured issuer supplies the token; Evidence Gateway verifies it as an ordinary resource server, knowing it as an issuer URL and a key - set. A deployment with an identity provider does not run Mint at all. + set under the immutable authentication profile. A bearer token, a declared purpose, a named requirement revision, and selector values go in; the answer, not the record, comes back. diff --git a/docs/site/scripts/check-breg-tutorial.sh b/docs/site/scripts/check-breg-tutorial.sh index c11ca8cec1..c691d366f2 100755 --- a/docs/site/scripts/check-breg-tutorial.sh +++ b/docs/site/scripts/check-breg-tutorial.sh @@ -17,7 +17,7 @@ # deliberately does not do. # # This gate builds the BReg toolset from the checked-out source unless BREG_BIN, -# BREGCTL_BIN and MINT_BIN select exact candidate or released bytes, then replays +# BREG_BIN and BREGCTL_BIN select exact candidate or released bytes, then replays # the registered tutorial's own shell fences from an empty reader directory, the # way a reader starts after installing the binaries. What CI runs is what a # reader copies. @@ -62,7 +62,7 @@ # is exactly when the journey is worth walking again. # # Configuration: -# BREG_BIN / BREGCTL_BIN / MINT_BIN run these exact binaries instead of +# BREG_BIN / BREGCTL_BIN run these exact binaries instead of # building from source # BREG_TUTORIAL_CARGO_PROFILE ci (default) or release # BREG_TUTORIAL_DOCS_ROOT docs content directory override (tests) @@ -99,7 +99,6 @@ EXCLUDED_BREG_TUTORIALS=( tutorials/build-a-breg-production-candidate # needs a reader-supplied signing key and a production database; product CI builds the candidate tutorials/extend-a-registry-with-a-module # authoring journey with editor steps on the project first-breg generates; replayable, not yet specified as a journey here tutorials/derive-a-registry-from-publicschema # derives a project from the embedded PublicSchema snapshot and edits a selection file; replayable, not yet specified as a journey here - tutorials/query-a-spatial-registry-from-qgis # needs QGIS on a desktop; product CI runs the spatial quickstart smoke tutorials/query-breg-client # BReg client journey; depends on the released unified packages, like query-relay-client tutorials/review-breg-changes-in-casework # two-product journey replayed by the Registry Casework tutorial gate, which builds the BReg toolset beside the Casework one tutorials/review-registry-changes # needs psql against the quickstart database and an editor step on change-control configuration; replayable, not yet specified as a journey here @@ -305,7 +304,7 @@ resolve_profile_dir() { } prepare_toolset() { - if [[ -z "${BREG_BIN:-}" || -z "${BREGCTL_BIN:-}" || -z "${MINT_BIN:-}" ]]; then + if [[ -z "${BREG_BIN:-}" || -z "${BREGCTL_BIN:-}" ]]; then local profile_dir profile_dir="$(resolve_profile_dir)" # The registry binary sits behind the runtime feature, exactly as the @@ -313,13 +312,12 @@ prepare_toolset() { (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ cargo build --locked --profile "$BUILD_PROFILE" \ -p registry-breg --features registry-breg/runtime \ - -p registry-bregctl -p registry-mint --bins) + -p registry-bregctl --bins) BREG_BIN="$TARGET_DIR/$profile_dir/breg" BREGCTL_BIN="$TARGET_DIR/$profile_dir/bregctl" - MINT_BIN="$TARGET_DIR/$profile_dir/mint" fi local bin - for bin in "$BREG_BIN" "$BREGCTL_BIN" "$MINT_BIN"; do + for bin in "$BREG_BIN" "$BREGCTL_BIN"; do # Absoluteness first: the reader journey runs from its own directory and # reaches the binaries through symlinks, so a relative path resolves # against the wrong directory and would otherwise surface much later, @@ -335,11 +333,10 @@ prepare_toolset() { done # The tutorial calls the binaries by name, and `bregctl dev` resolves - # `breg` and `mint` from PATH, so serve them from a shim dir. + # `breg` from PATH, so serve both commands from a shim directory. mkdir -p "$SHIM_DIR" ln -s "$BREG_BIN" "$SHIM_DIR/breg" ln -s "$BREGCTL_BIN" "$SHIM_DIR/bregctl" - ln -s "$MINT_BIN" "$SHIM_DIR/mint" } # --------------------------------------------------------------------------- diff --git a/docs/site/scripts/check-casework-tutorial.sh b/docs/site/scripts/check-casework-tutorial.sh index f96539365b..d423c9e363 100755 --- a/docs/site/scripts/check-casework-tutorial.sh +++ b/docs/site/scripts/check-casework-tutorial.sh @@ -17,7 +17,7 @@ # deliberately does not do. # # This gate builds the toolset from the checked-out source unless CASEWORK_BIN, -# CASEWORKCTL_BIN, MINT_BIN, BREG_BIN and BREGCTL_BIN select exact candidate or +# CASEWORKCTL_BIN, BREG_BIN and BREGCTL_BIN select exact candidate or # released bytes, then replays each registered tutorial's own shell fences from # an empty reader directory, the way a reader starts after installing the # binaries. The Base Registry Engine binaries are part of the toolset because @@ -65,7 +65,7 @@ # is exactly when the journey is worth walking again. # # Configuration: -# CASEWORK_BIN / CASEWORKCTL_BIN / MINT_BIN run these exact binaries instead +# CASEWORK_BIN / CASEWORKCTL_BIN run these exact binaries instead # BREG_BIN / BREGCTL_BIN of building from source # CASEWORK_TUTORIAL_CARGO_PROFILE ci (default) or release # CASEWORK_TUTORIAL_DOCS_ROOT docs content directory override (tests) @@ -360,23 +360,21 @@ resolve_profile_dir() { } prepare_toolset() { - if [[ -z "${CASEWORK_BIN:-}" || -z "${CASEWORKCTL_BIN:-}" || -z "${MINT_BIN:-}" || - -z "${BREG_BIN:-}" || -z "${BREGCTL_BIN:-}" ]]; then + if [[ -z "${CASEWORK_BIN:-}" || -z "${CASEWORKCTL_BIN:-}" || -z "${BREG_BIN:-}" || -z "${BREGCTL_BIN:-}" ]]; then local profile_dir profile_dir="$(resolve_profile_dir)" (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ cargo build --locked --profile "$BUILD_PROFILE" \ - -p registry-casework -p registry-caseworkctl -p registry-mint \ + -p registry-casework -p registry-caseworkctl \ -p registry-breg --features registry-breg/runtime \ -p registry-bregctl --bins) CASEWORK_BIN="$TARGET_DIR/$profile_dir/casework" CASEWORKCTL_BIN="$TARGET_DIR/$profile_dir/caseworkctl" - MINT_BIN="$TARGET_DIR/$profile_dir/mint" BREG_BIN="$TARGET_DIR/$profile_dir/breg" BREGCTL_BIN="$TARGET_DIR/$profile_dir/bregctl" fi local bin - for bin in "$CASEWORK_BIN" "$CASEWORKCTL_BIN" "$MINT_BIN" "$BREG_BIN" "$BREGCTL_BIN"; do + for bin in "$CASEWORK_BIN" "$CASEWORKCTL_BIN" "$BREG_BIN" "$BREGCTL_BIN"; do # Absoluteness first: the reader journey runs from its own directory and # reaches the binaries through symlinks, so a relative path resolves # against the wrong directory and would otherwise surface much later, @@ -392,12 +390,12 @@ prepare_toolset() { done # The tutorials call the binaries by name, `caseworkctl dev` resolves - # `casework`, `mint` and `bregctl` from PATH, and `bregctl dev` resolves - # `breg` and `mint` the same way, so serve all five from a shim dir. + # `casework` and `bregctl` from PATH, and `bregctl dev` resolves + # `breg` the same way, so serve all five from a shim dir. mkdir -p "$SHIM_DIR" ln -s "$CASEWORK_BIN" "$SHIM_DIR/casework" ln -s "$CASEWORKCTL_BIN" "$SHIM_DIR/caseworkctl" - ln -s "$MINT_BIN" "$SHIM_DIR/mint" + ln -s "$SHIM_DIR/mint" ln -s "$BREG_BIN" "$SHIM_DIR/breg" ln -s "$BREGCTL_BIN" "$SHIM_DIR/bregctl" } diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index b5b8d4eeb5..129c4da5fa 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -17,7 +17,7 @@ # a human reader once already. # # This gate builds the Evidence toolset from the checked-out source unless -# EVIDENCE_BIN, EVIDENCECTL_BIN, EVIDENCE_OID4VCI_BIN and MINT_BIN select exact +# EVIDENCE_BIN, EVIDENCECTL_BIN, and EVIDENCE_OID4VCI_BIN select exact # candidate or released bytes, then replays each registered tutorial's own # shell fences in its own reader directory. Every tutorial creates the files it # needs from its documented commands, so what CI runs is what a reader copies. @@ -83,7 +83,7 @@ # # Configuration: # EVIDENCE_BIN / EVIDENCECTL_BIN / run these exact binaries instead of -# EVIDENCE_OID4VCI_BIN / MINT_BIN building from source +# EVIDENCE_OID4VCI_BIN building from source # EVIDENCE_OID4VCI_INTEROP_TEST_BIN run this prebuilt sanitized flow test # REGISTRY_CLIENT_PY_WHEEL import the client package out of this # assembled wheel @@ -138,10 +138,8 @@ EXCLUDED_EVIDENCE_TUTORIALS=( send-registry-events-to-a-webhook # Base Registry Engine journey; needs the demo launcher's webhook receiver, verified in reader mode outside the Evidence runner build-a-breg-production-candidate # Base Registry Engine journey; needs a PostgreSQL container and a local signing key, verified in reader mode outside the Evidence runner query-breg-client # BReg client journey; depends on the released unified packages, like query-relay-client - query-a-spatial-registry-from-qgis # Base Registry Engine spatial journey; product CI runs the spatial smoke, while QGIS needs a desktop reader run integrate-evidence-candidate-with-docker-compose # drift-checked by evidence-production-build-docs.test.mjs; needs Docker Compose issue-a-birth-certificate-vc-from-opencrvs # needs the public OpenCRVS Farajaland demo; live and opt-in, not replayed in CI - issue-evidence-access-tokens-with-registry-mint # drift-checked by evidence-production-build-docs.test.mjs; needs a Registry Mint deployment issue-immunization-evidence-from-dhis2 # needs the public DHIS2 demo; live and opt-in, not replayed in CI 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 @@ -315,12 +313,12 @@ load_spec() { "run:Register the first local application" "run:Start the protected service" "run:Make an allowed request" - "run:Add an application without restarting" + "run:Add an application for the next generation" "run:Use the application assigned the policy" "run:Try a question the application was not granted" "run:Revoke an application|1" "run-fails:Revoke an application|2" - "run:Inspect the final audit operation" + "run:Revoke an application|3" "run:Clean up" ) # This tutorial teaches refusal, so the refusals are what must hold. @@ -547,21 +545,20 @@ SHIM_DIR="$WORK_ROOT/bin" prepare_toolset() { if [[ -z "${EVIDENCE_BIN:-}" || -z "${EVIDENCECTL_BIN:-}" || \ - -z "${EVIDENCE_OID4VCI_BIN:-}" || -z "${MINT_BIN:-}" ]]; then + -z "${EVIDENCE_OID4VCI_BIN:-}" ]]; then local profile_dir profile_dir="$(resolve_profile_dir)" (cd "$REPO_ROOT" && CARGO_TARGET_DIR="$TARGET_DIR" \ cargo build --locked --profile "$BUILD_PROFILE" \ -p registry-evidence -p registry-evidencectl \ - -p registry-evidence-oid4vci -p registry-mint) + -p registry-evidence-oid4vci) EVIDENCE_BIN="$TARGET_DIR/$profile_dir/evidence" EVIDENCECTL_BIN="$TARGET_DIR/$profile_dir/evidencectl" EVIDENCE_OID4VCI_BIN="$TARGET_DIR/$profile_dir/evidence-oid4vci" - MINT_BIN="$TARGET_DIR/$profile_dir/mint" fi export EVIDENCE_OID4VCI_BIN local bin - for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN" "$EVIDENCE_OID4VCI_BIN" "$MINT_BIN"; do + for bin in "$EVIDENCE_BIN" "$EVIDENCECTL_BIN" "$EVIDENCE_OID4VCI_BIN"; do # Absoluteness first: the reader journey runs from its own directory and # reaches the binaries through symlinks, so a relative path resolves # against the wrong directory and would otherwise surface much later, @@ -581,7 +578,6 @@ prepare_toolset() { ln -s "$EVIDENCE_BIN" "$SHIM_DIR/evidence" ln -s "$EVIDENCECTL_BIN" "$SHIM_DIR/evidencectl" ln -s "$EVIDENCE_OID4VCI_BIN" "$SHIM_DIR/evidence-oid4vci" - ln -s "$MINT_BIN" "$SHIM_DIR/mint" } # The unified client package, unpacked once for whichever tutorials import it. diff --git a/docs/site/scripts/check-notary-surface.mjs b/docs/site/scripts/check-notary-surface.mjs index 7480442e25..5a728afa7a 100644 --- a/docs/site/scripts/check-notary-surface.mjs +++ b/docs/site/scripts/check-notary-surface.mjs @@ -170,7 +170,7 @@ export async function checkNotarySurface(siteRoot = defaultSiteRoot) { [ 'Current documentation names Registry Notary, which no adopter can meet.', ...findings.map(({ path, line, excerpt }) => `${path}:${line}: ${excerpt}`), - 'Rewrite the page around Registry Relay, Evidence, and Registry Mint. Leave', + 'Rewrite the page around Registry Relay and Evidence. Leave', 'the retirement itself to the decision record and the changelog. Where a', 'shipped schema, validator, or image name still spells the identifier,', 'write it as code and say nothing about the product behind it.', diff --git a/docs/site/scripts/check-notary-surface.test.mjs b/docs/site/scripts/check-notary-surface.test.mjs index 0eb00ed4d3..0383d6aab7 100644 --- a/docs/site/scripts/check-notary-surface.test.mjs +++ b/docs/site/scripts/check-notary-surface.test.mjs @@ -58,7 +58,7 @@ test('rejects a current page that names Notary only to say it is retired', async 'status: current', [ 'Registry Notary is retired. The stability promises below cover', - 'Registry Relay, Evidence, and Registry Mint.', + 'Registry Relay and Evidence.', '', '| Registry Notary | Retired | No stability promise remains. |', ].join('\n'), diff --git a/docs/site/scripts/cli-reference-sidebar.test.mjs b/docs/site/scripts/cli-reference-sidebar.test.mjs index c1d584e504..671ab5eda3 100644 --- a/docs/site/scripts/cli-reference-sidebar.test.mjs +++ b/docs/site/scripts/cli-reference-sidebar.test.mjs @@ -19,10 +19,10 @@ test('pinned docsets expose CLI navigation only when they contain its index', as // A catalog that predates a binary contains no page for it, so the binary // takes no seat and the navigation stays true to what the docset publishes. await writeFile(index, page('CLI reference')); - await writeFile(resolve(root, 'mint.mdx'), page('mint command reference')); + await writeFile(resolve(root, 'evidence.mdx'), page('evidence command reference')); assert.deepEqual( cliReferenceSidebar(index)[0].items.map((item) => item.slug), - ['reference/cli', 'reference/cli/mint'], + ['reference/cli', 'reference/cli/evidence'], ); await writeFile(index, draftPage('CLI reference')); diff --git a/docs/site/scripts/evidence-production-build-docs.test.mjs b/docs/site/scripts/evidence-production-build-docs.test.mjs index e3d35489d0..75ac4126a9 100644 --- a/docs/site/scripts/evidence-production-build-docs.test.mjs +++ b/docs/site/scripts/evidence-production-build-docs.test.mjs @@ -28,13 +28,11 @@ async function page(path) { // deployment must be, and pinning them here only makes these pages harder to // write. If you are adding an assertion because a page happens to contain a // string, stop. -test('production Evidence tutorials keep their secret handling, signing, and Mint boundaries explicit', async () => { - const [build, transit, rotation, mint, compose] = await Promise.all([ +test('production Evidence tutorials keep their secret handling and signing boundaries explicit', async () => { + const [build, transit, rotation] = await Promise.all([ page('src/content/docs/tutorials/build-and-deploy-evidence-project.mdx'), page('src/content/docs/tutorials/move-evidence-to-production-signing.mdx'), page('src/content/docs/tutorials/rotate-evidence-signing-keys.mdx'), - page('src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx'), - page('src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx'), ]); // The access token goes into an owner-only file, never onto a command line or @@ -54,29 +52,15 @@ test('production Evidence tutorials keep their secret handling, signing, and Min // Rotation and revocation both depend on the floor that stops a retired // version from signing again. assert.match(rotation, /min_encryption_version/u); - // Mint is optional, signs through Transit rather than a local private key, - // and states the replay-protection limit an operator must not overclaim. - assert.match(mint, /Evidence Gateway does not require Mint/u); - assert.match(mint, /signer\.kind: transit/u); - assert.match(mint, /memory-only/u); - // The issued token is created owner-only and removed after use. - assert.match(mint, /umask 077/u); - assert.match(mint, /rm -f ""/u); - // Two services, two signing paths: sharing one would let either sign as the - // other. - assert.match(compose, /Do not share the Evidence Gateway proxy or socket with Mint/u); }); -test('the maintained Compose adapter keeps Evidence independent from Mint scaffolding', async () => { +test('the maintained Compose adapter keeps Evidence deployment inputs explicit', async () => { const [readme, compose, runtime] = await Promise.all([ readFile(resolve(repoRoot, 'docker/compose/README.md'), 'utf8'), readFile(resolve(repoRoot, 'docker/compose/docker-compose.yaml'), 'utf8'), readFile(resolve(repoRoot, 'docker/compose/runtime.docker.yaml'), 'utf8'), ]); - assert.doesNotMatch(readme, /--with-mint/u); - assert.doesNotMatch(compose, /--with-mint/u); - assert.doesNotMatch(compose, /MINT_(?:IMAGE|CONFIG_DIR|SECRET_ROOT)/u); assert.match(readme, /intentionally absent from the base adapter/u); for (const name of [ 'EVIDENCE_CANDIDATE_DIR', diff --git a/docs/site/scripts/generate-breg-configuration.test.mjs b/docs/site/scripts/generate-breg-configuration.test.mjs index 92475ec015..7cea266f81 100644 --- a/docs/site/scripts/generate-breg-configuration.test.mjs +++ b/docs/site/scripts/generate-breg-configuration.test.mjs @@ -28,7 +28,7 @@ test('BReg reference includes module extensions, event conditions, and runtime d const project = fields('project'); const module = fields('module'); const runtime = fields('runtime'); - assert.ok(project.has('accessProfiles[].grants[].entity')); + assert.ok(project.has('accessProfiles[].permissions[].entity')); assert.deepEqual(module.get('extendEntities[].events[].trigger').values, ['created', 'patched', 'request_lifecycle', 'tombstoned']); for (const path of ['changed[]', 'beforeEquals.*', 'afterEquals.*']) { assert.ok(module.has(`extendEntities[].events[].when.${path}`), path); diff --git a/docs/site/scripts/generate-cli-reference.mjs b/docs/site/scripts/generate-cli-reference.mjs index f059869bec..675a4e1be7 100644 --- a/docs/site/scripts/generate-cli-reference.mjs +++ b/docs/site/scripts/generate-cli-reference.mjs @@ -32,7 +32,6 @@ export const expectedBinaries = [ 'evidence', 'evidence-oid4vci', 'evidencectl', - 'mint', 'relay', 'relayctl', ]; @@ -52,10 +51,7 @@ const groups = [ { title: 'Registry Casework', binaries: ['casework', 'caseworkctl'] }, { title: 'Registry Relay', binaries: ['relay', 'relayctl'] }, { title: 'Evidence Gateway', binaries: ['evidence', 'evidencectl'] }, - { - title: 'Supporting Evidence services', - binaries: ['mint', 'evidence-oid4vci'], - }, + { title: 'Evidence credential delivery', binaries: ['evidence-oid4vci'] }, ]; function exactKeys(value, expected, label) { diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 355577b9a5..443f9853df 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -198,7 +198,6 @@ test('uses the product navigation in its published order', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', - 'Registry Mint', 'Registry Discovery', 'Operations', 'Design', @@ -280,6 +279,7 @@ test('keeps consumer and wallet-provider guidance in separate Evidence groups', assert.ok(wallet, 'Evidence must provide a separate wallet-provider entry point'); assert.deepEqual(consumer.items.map((item) => item.slug), [ 'tutorials/request-evidence-from-an-application', + 'configure/request-an-access-token', 'tutorials/verify-an-assertion-as-a-consumer', 'tutorials/manage-evidence-verifier-trust', 'explanation/openfn-adaptors', @@ -318,20 +318,18 @@ test('uses the formal product names for top-level sections', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', - 'Registry Mint', 'Registry Discovery', ]) { assert.ok(topLevelSection(sidebarSource, product), `could not isolate ${product}`); } // Short forms are what made one product look like several. `Relay`, `BReg`, - // `Mint`, and `Discovery` are ordinary inside a page that has already named + // and `Discovery` are ordinary inside a page that has already named // the product; a top-level label is where a reader arrives, so it carries // the full name or none at all. for (const label of topLevelLabels(sidebarSource)) { for (const [shortForm, formal] of [ ['Relay', 'Registry Relay'], - ['Mint', 'Registry Mint'], ['Discovery', 'Registry Discovery'], ['BReg', 'Base Registry Engine'], ['Evidence', 'Evidence Gateway'], @@ -460,7 +458,6 @@ test('keeps the BReg guide and references in one adoption path', () => { 'tutorials/derive-a-registry-from-publicschema', 'tutorials/review-registry-changes', 'tutorials/send-registry-events-to-a-webhook', - 'tutorials/query-a-spatial-registry-from-qgis', 'configure/breg', 'configure/breg-access', 'configure/breg-change-control', @@ -622,9 +619,8 @@ test('organizes Evidence Gateway tasks without publishing the obsolete Relay com 'Evidence Gateway task group', ); assert.doesNotMatch(evidence, /label: 'Verify and trust'/); - // Token issuance stays with Registry Mint. The Evidence consumer group is - // separate from provider authoring and wallet-delivery guidance. - assert.doesNotMatch(evidence, /label: 'Registry Mint'/); + // Token issuance stays with the deployment's identity provider. The Evidence + // consumer group is separate from provider authoring and wallet-delivery guidance. assert.doesNotMatch(evidence, /label: 'Verify as a relying party'/); // explanation/integration-patterns held two seats, which left Starlight // unable to say which one is the active page and made prev/next ambiguous. diff --git a/docs/site/scripts/replay-protection-contract.test.mjs b/docs/site/scripts/replay-protection-contract.test.mjs index 35047381e1..98a3ae5095 100644 --- a/docs/site/scripts/replay-protection-contract.test.mjs +++ b/docs/site/scripts/replay-protection-contract.test.mjs @@ -14,78 +14,28 @@ const replaySection = spec.match( )?.groups?.body; assert.ok(replaySection, 'RS-SEC-G must contain the replay-protection authority section'); - -// Prose wraps at the source margin, so sentence-level assertions read a -// whitespace-normalized copy. Table rows and links are checked against the raw -// section, where the exact shape is the thing being guarded. const prose = replaySection.replace(/\s+/g, ' '); -test('RS-SEC-G keeps the exact product replay matrix', () => { - const productRows = replaySection.match(/^\| Registry \w+ \|.*$/gm) ?? []; - assert.equal(productRows.length, 1, 'expected one replay-contract row per product'); - - assert.match( - replaySection, - /\| Registry Mint \| Single use of a client assertion identifier at the token endpoint[.] \|[\s\S]*?verified against the named client's registered keys[\s\S]*?\| The assertion's own expiry, bounded by the configured maximum assertion lifetime,/, - ); -}); - -// A compiled Relay operation is a read, so Relay owns no replay decision and the -// page must not imply that its pagination cursor is a single-use token. -test('RS-SEC-G states that Registry Relay holds no replay state', () => { - assert.doesNotMatch(replaySection, /^\| Registry Relay \|/m); - assert.match(prose, /Registry Relay holds no replay state/); +test('RS-SEC-G records the maintained no-replay-state boundary', () => { assert.match( prose, - /A cursor is bound request context, not a single-use token, and a deployment MUST NOT present it as one/, + /No maintained Registry Stack resource server holds a replay table, replay reservation, or persistent single-use record/, ); -}); - -// Evidence has no replay subsystem, and the page must not imply that the echoed -// request nonce is one. -test('RS-SEC-G states that Evidence Gateway holds no replay state', () => { - assert.match(prose, /Evidence Gateway holds no replay state at all/); - assert.match(prose, /never stored, uniqueness-checked, or exposed/); assert.match( prose, - /A service that holds no replay state MUST NOT be presented as providing replay, single-use, or freshness protection/, + /Replay prevention belongs to the surrounding protocol or issuer that creates and validates a one-time challenge/, ); + assert.doesNotMatch(replaySection, /^\| Registry /m); }); -// Replay authority stays product-owned. Nothing in the maintained stack keeps a -// replay decision in shared storage, so the page must not acquire one. -test('RS-SEC-G keeps replay authority product-owned and isolated', () => { +test('RS-SEC-G does not turn validation into credential consumption', () => { assert.match( prose, - /Registry Mint's client-assertion cache is the only replay authority this stack holds/, + /A service that holds no replay state MUST NOT claim replay prevention or single-use enforcement/, ); assert.match( prose, - /No maintained service persists a replay decision across restart, and none keeps replay correctness state in a database/, + /signature[s]?, expiry, audience, nonce equality, and other request bindings/, ); - assert.doesNotMatch(replaySection, /\bRedis\b/i); - // V1 vocabulary the cutover removed from this section. Relay held replay state - // in PostgreSQL and keyed it on a consultation batch child; V2 holds none, so - // either name reappearing here is drift rather than a new claim. - assert.doesNotMatch(replaySection, /\bPostgreSQL\b/i); - assert.doesNotMatch(replaySection, /batch-child/i); -}); - -// In-process single use is not a cross-instance guarantee, and the page must -// not let a reader read it as one. -test('RS-SEC-G bounds in-process single-use enforcement', () => { - assert.match(prose, /MUST fail closed when the cache is saturated rather than evict a live entry/); - assert.match( - prose, - /deployment that runs more than one instance MUST NOT claim single use across those instances/, - ); -}); - -test('RS-SEC-G links retention and Relay recovery', () => { - assert.match(replaySection, /\[retention and persistent-state reference\]\(\.\.\/\.\.\/operate\/retention-and-persistent-state\/\)/); - // Relay V2 retired the standalone backup-and-restore page. The recovery steps - // it carried now live on the Relay operations page, which is where this - // section has to send an operator. - assert.match(replaySection, /\]\(\.\.\/\.\.\/operate\/relay\/\)/); - assert.doesNotMatch(replaySection, /operate\/backup-and-restore/); + assert.doesNotMatch(replaySection, /\bRedis\b|\bPostgreSQL\b|batch-child/i); }); diff --git a/docs/site/src/content/docs/configure/breg-access.mdx b/docs/site/src/content/docs/configure/breg-access.mdx index cb20d14829..55647883a6 100644 --- a/docs/site/src/content/docs/configure/breg-access.mdx +++ b/docs/site/src/content/docs/configure/breg-access.mdx @@ -156,7 +156,7 @@ The operations a permission may list: | `apply_request` | Applying an approved request, limited by `applyTargets`. | | `invoke` | Invoking an immediate action. | -{/* Evidence: products/breg/acceptance/asset-site-placement-change-requests/registry.yaml; products/breg/acceptance/business-establishments/registry.yaml; products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/auth.rs; crates/registry-breg/src/contract.rs, AccessProfileSource and AccessGrantSource; crates/registry-breg/src/access.rs; crates/registry-breg/src/change_request.rs. */} +{/* Evidence: products/breg/acceptance/asset-site-placement-change-requests/registry.yaml; products/breg/acceptance/business-establishments/registry.yaml; products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/auth.rs; crates/registry-breg/src/contract.rs, AccessProfileSource and AccessPermissionSource; crates/registry-breg/src/access.rs; crates/registry-breg/src/change_request.rs. */} ## Access requirements diff --git a/docs/site/src/content/docs/configure/breg-change-control.mdx b/docs/site/src/content/docs/configure/breg-change-control.mdx index d5e14873cb..e1c48e0b69 100644 --- a/docs/site/src/content/docs/configure/breg-change-control.mdx +++ b/docs/site/src/content/docs/configure/breg-change-control.mdx @@ -280,7 +280,7 @@ accessProfiles: At runtime the engine reuses that same target permission's current `get` authority, never the request entity's own boundaries. Two checks carry it. The route itself refuses `create`, a draft `patch`, `submit_request`, and `revise_request` unless the caller's claims resolve to target authority under that permission: a claim that carries no matching value, or one a boundary cannot reduce to the single value it needs, leaves the route unauthorized and answers `404`, the same concealment an unrelated caller gets. Then `submit_request` and `revise_request` admit the named target rows inside the request transaction, under a share lock on each target table, and an exact idempotent replay admits them again before it replays: a target row the caller can no longer read through that permission answers `412` and `precondition.failed`. Reads are symmetric: once the request is retained, its target links appear only to a reader who currently holds that same `get` authority. -{/* Evidence: products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/contract.rs, AccessGrantSource; crates/registry-breg/src/change_request.rs; crates/registry-breg/src/mutation/request.rs, admit_submitter_targets(); crates/registry-breg/src/api/mod.rs, authorize_direct_route_base(); crates/registry-breg/src/idempotency.rs; crates/registry-breg/src/postgres/request_read.rs, target_get_is_authorized(); crates/registry-breg/src/problem.rs. */} +{/* Evidence: products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/contract.rs, AccessPermissionSource; crates/registry-breg/src/change_request.rs; crates/registry-breg/src/mutation/request.rs, admit_submitter_targets(); crates/registry-breg/src/api/mod.rs, authorize_direct_route_base(); crates/registry-breg/src/idempotency.rs; crates/registry-breg/src/postgres/request_read.rs, target_get_is_authorized(); crates/registry-breg/src/problem.rs. */} ### Plan effects with a Rhai script diff --git a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx index 3d29409917..73f1b35fcb 100644 --- a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx +++ b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx @@ -21,8 +21,8 @@ certification or compatibility with a wallet product. ## When to use evidence-oid4vci -`evidence-oid4vci` is a supporting service, not a product pattern of its own, in the same sense -Registry Mint is. It exposes Evidence Gateway credentials over OID4VCI 1.0 Final, the +`evidence-oid4vci` is a supporting service, not a product pattern of its own. It exposes Evidence +Gateway credentials over OID4VCI 1.0 Final, the wallet-facing protocol Evidence Gateway deliberately refuses to speak. Evidence Gateway signs every credential. The adapter holds no Evidence Gateway signing key and @@ -104,7 +104,7 @@ You need: - A complete Evidence Gateway deployment with at least one requirement declared `holder-bound`, permitted for the SD-JWT VC batch format at bundle and grant scope. -- A Registry Mint deployment, or another token issuer, that can grant `evidence-oid4vci` its own +- An OAuth token issuer that can grant `evidence-oid4vci` its own access token to Evidence Gateway through the `private_key_jwt` client assertion flow, and this service's own private JWK for that assertion. - A published `https` origin for the credential issuer identifier this service will serve. A @@ -226,8 +226,8 @@ evidence-oid4vci check --config /etc/evidence-oid4vci/oid4vci.yaml `check` does everything `serve` does except bind a socket: it loads and validates the configuration document and the token client key, then exits. An old `mint:` block fails with an instruction to use `tokenClient:`. -When retaining Registry Mint as the token issuer, leave `resource` or `scopes` out if that -installation does not accept those OAuth request parameters. `check`, `inspect`, and `serve` accept +Configure `resource` and `scopes` to match the issuer's registered client and Evidence Gateway +authorization profile. `check`, `inspect`, and `serve` accept `EVIDENCE_OID4VCI_CONFIG` in place of `--config`. Inspect the metadata derived from the validated configuration and authenticated Evidence @@ -424,7 +424,5 @@ proofs, holder keys, credentials, selectors, subject identifiers, raw issuers, o ## Next -- [Configure Registry Mint](../mint/) if you retain Mint to issue the access tokens used for - Evidence Gateway and the adapter's `POST /offers` endpoint. - [Configure Evidence Gateway](../evidence/) for the deployment this service requests credentials from. diff --git a/docs/site/src/content/docs/configure/evidence.mdx b/docs/site/src/content/docs/configure/evidence.mdx index 09603f5027..2af3614966 100644 --- a/docs/site/src/content/docs/configure/evidence.mdx +++ b/docs/site/src/content/docs/configure/evidence.mdx @@ -241,7 +241,7 @@ The runtime names its Unix socket, mount, key name, pinned nonzero version, and The private key stays in Transit. Startup validates the configured public P-256 JWK, pinned version, custody controls, and a sign-and-verify operation before the deployment is ready. Follow -[Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/) +[Configure Transit signing for Evidence Gateway](../../tutorials/move-evidence-to-production-signing/) for provider key, public JWK, proxy, and policy setup. ### `audit-hmac-key` @@ -421,8 +421,8 @@ command contract. ## Next -- [Configure Registry Mint](../mint/) to issue access tokens for this deployment when it has no - other identity provider. +- Configure the deployment's OAuth issuer to issue access tokens matching this bundle's + authentication and authority profile. - [Enable SD-JWT VC in a deployment](../enable-sd-jwt-vc/) to serialize the same assertion as a wallet-usable credential instead of a signed JWS. - [Verify and interpret the Evidence Gateway audit chain](../../operate/evidence-audit/) for the diff --git a/docs/site/src/content/docs/configure/mint.mdx b/docs/site/src/content/docs/configure/mint.mdx deleted file mode 100644 index 149440851f..0000000000 --- a/docs/site/src/content/docs/configure/mint.mdx +++ /dev/null @@ -1,380 +0,0 @@ ---- -title: Configure Registry Mint -description: Configure Registry Mint to issue short-lived, audience-bound access tokens for Evidence Gateway or Registry Relay when a deployment has no identity provider. -status: current -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-08-12" -doc_type: how-to -locale: en -standards_referenced: [] ---- - -Configure Registry Mint when Evidence Gateway or Registry Relay needs short-lived access tokens -for a closed set of registered machine clients and the deployment has no identity provider to -issue them. - -## When to use Registry Mint - -Registry Mint is a supporting service, not a product pattern of its own. Use it when a resource -server such as Evidence Gateway or Registry Relay needs signed, expiring, audience-bound tokens -and standing up a general-purpose identity provider is not an option for the deployment. - -Version 1 runs one active Mint process. Its client-assertion replay cache is memory-only and -clears on restart. Do not claim high availability or durable replay protection for this deployment -shape. - -Registry Mint answers a narrower question than a shared JWKS can. A pooled key set can only say -that a token was signed by a trusted key; it cannot say which caller signed it or what that -caller is permitted to assert. Registry Mint splits the two questions across two places: the -client registry binds a client id to its selected authentication material and to the authority -Registry Mint will assert for it, and the token endpoint verifies an incoming request only against -that client's registration, then writes the authority from the registry, never from the request. - -For the whole round trip in one picture, from a client holding only its own private key to a -signed assertion, see -[how a client, Registry Mint, and Evidence Gateway interact](../../reference/mint/#how-a-client-registry-mint-and-evidence-gateway-interact). - -## When not to use Registry Mint - -Skip Registry Mint when an identity provider already issues client-credentials tokens for the -deployment. Registry Mint exists only for the case where none does; pointing Evidence Gateway at an -existing IdP's token endpoint and JWKS does not require Registry Mint at all. - -Registry Mint is also not a place to route caller identity for people. It authenticates -registered machine clients, using a private key by default or an explicitly selected secret for -a managed standard client. A client secret identifies that installation, not the person using it. -Any token bound to one named person rides inside a private-key client's signed request rather than -a separate login. If a deployment needs a person to authenticate directly, that is an identity -provider's job, not Registry Mint's. - -## Before you start - -You need: - -- The registered clients this deployment will serve. Each client needs one id, one principal, - and either Evidence authority fields or a standard authorization block with fixed scopes and - direct claims. -- A governed public P-256 JWK for Registry Mint itself. Its service `kid` is the derived RFC 7638 - thumbprint. Strict deployments use a workload-local Vault or OpenBao Transit proxy; supervised - local development may use an owner-only local P-256 private JWK. -- An independently generated audit HMAC key containing at least 32 bytes, stored in another - owner-only, non-symlink file. -- A private JWK per preferred `private-key-jwt` client. Registry Mint only ever stores and reads - each client's public half; keep the private half with the client. A managed client that cannot - sign assertions may instead use one Mint-generated, per-installation client secret with a - standard authorization registration. -- A directory to hold one registration file per client. -- An owner-only directory on durable storage for the keyed Mint audit chain. Plan its capacity, - backup, and retention because Registry Mint rotates segments but never deletes or compacts - them. -- For an Evidence deployment, the claim names Evidence Gateway reads for its principal, - requester tags, evidence audience, and grant pair. Registry Mint's `accessTokens.claims` must - match them exactly. A scoped-only Relay deployment omits this block. -- TLS in front of Registry Mint for strict deployments. Registry Mint serves plain HTTP and expects - TLS termination it does not manage. Supervised local development alone admits the exact - `http://127.0.0.1:` issuer and matching token and JWKS paths. - -## Configure the deployment - -Registry Mint reads one YAML document. Every relative path in it resolves against the document's -own directory, and every field in it is startup-only: changing issuer identity, signing keys, -the listener, or token policy means restarting the process. - -```yaml -version: 1 -validationMode: strict -issuer: https://mint.example.org -listener: - address: 127.0.0.1 - port: 8081 -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: 7 - timeoutMilliseconds: 2000 -secretProviders: - file: - root: /run/registry-mint/secrets -audit: - path: audit/mint.jsonl - maximumFileBytes: 1073741824 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [evidence] - lifetimeSeconds: 300 - claims: - principal: sub - requesterTags: evidence_tags - evidenceAudience: evidence_audience - grantId: evidence_grant_id - grantAuthority: evidence_authority -clientAssertion: - audience: https://mint.example.org/token - algorithms: [EdDSA, ES256, RS256] -clients: - directory: clients -``` - -`issuer` must be an `https` URL with a host and no credentials, query, or fragment; resource -servers compare it exactly. `accessTokens.lifetimeSeconds` is bounded to `60..=3600` for -Evidence-only deployments. A standard authorization registration reduces the maximum to 900 -seconds so the token cannot exceed Registry Relay's fixed lifetime ceiling. -`accessTokens.claims` must match Evidence Gateway's own claim names field for field. A deployment -may omit `accessTokens.claims` only when every registered client uses standard authorization. -`clientAssertion.audience` is the value every client's signed request must carry as its own -`aud`, which stops a request built for one endpoint from being replayed at another. - -`validationMode` defaults to `strict`, which requires the Transit signer. Set it to -`supervised-local-development` only for the disposable local developer environment; that mode may -use `signer.kind: local-jwk` with `privateKeyRef: secret:file/`. -Use -[Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/) -to provision a strict signer and its governed public JWK. - -The secret named by `audit.hashKeyRef` must contain at least 32 bytes and remain separate from the signing key. Mint -verifies the keyed JSONL chain and takes a single-writer lock at startup. Before returning an -access token it synchronizes a token-release record to the chain and its parent directory. If the -write fails, Mint returns `server_error`, does not release the token, and fails readiness. The -chain keeps only keyed pseudonyms where correlation is needed, never raw assertions, access -tokens, client ids, authority values, actors, or subject values. -`audit.maximumFileBytes` is the per-segment rotation threshold. Mint seals a full segment as -`.` and continues online at `audit.path`; it never deletes or -compacts sealed segments. - -Delegated tokens, bound to one named subject a client acts on behalf of, are a further optional -step layered on top of this base configuration. `crates/registry-mint/README.md` covers the -`accessTokens.claims.actor` field and the per-client `delegation` block that step needs; this -walkthrough covers the base, undelegated flow. - -For the complete field list, including every default, see the -[Registry Mint reference](../../reference/mint/). - -## Register an Evidence client - -Add one file per client under the directory named in `clients.directory`: - -```yaml -clientId: permit-desk -principal: service:permit-desk -evidenceAudience: https://evidence.example.org -requesterTags: [permit-desk, region-north] -keys: - - kty: OKP - crv: Ed25519 - kid: permit-desk-2026-01 - x: "" -``` - -`keys` accepts public JWKs only; a document carrying a private key member is rejected outright. -Loading the client registry is all-or-nothing, so one malformed registration fails the whole -load rather than serving a partial registry. - -## Register a Relay client - -Use the standard authorization profile when Registry Mint issues tokens for Registry Relay: - -```yaml -clientId: registry-consumer -principal: urn:example:consumer -authorization: - scopes: - - registry:business:read - claims: - purpose: statutory-consultation - authority: district-17 -keys: - - kty: OKP - crv: Ed25519 - kid: registry-consumer-2026-01 - x: "" -``` - -Set `accessTokens.audiences` to the one exact audience in the Relay runtime's issuer -configuration. Set `authorization.scopes` to the exact operation or access-profile scopes this -client may use. Add a claim under `authorization.claims` only when the compiled Relay -access rule reads that claim for purpose or authority-to-row binding. - -A claim value is either one string or a list of strings: - -```yaml -authorization: - scopes: - - registry:business:read - claims: - purpose: statutory-consultation - authority: [district-17, district-18] -``` - -Registry Mint mints the single string as a JSON string and the list as a JSON array. A resource -server boundary that matches a row against a set of permitted values reads an array-valued claim -and refuses a scalar, so name every permitted value in the list form when the access rule binds -rows that way. - -The registration is the only authority source. Registry Mint joins the registered scopes into -the access token's space-delimited `scope` claim and copies the direct claims unchanged. The -client assertion and token form cannot provide or override these values. A Relay registration -cannot also contain `evidenceAudience`, `requesterTags`, `grant`, or `delegation`. - -The standard profile admits 1 to 64 unique RFC 6749 scope-tokens and at most 32 direct claims. -Each direct value is a non-empty string of at most 512 bytes, or a list of 1 to 64 unique such -strings. Claim names cannot shadow `iss`, `aud`, `exp`, `iat`, `nbf`, `jti`, `client_id`, `sub`, -or `scope`. -When `accessTokens.claims` is configured for Evidence clients in the same deployment, a scoped -client's direct claims cannot reuse any of those configured Evidence names. -Mint projects the complete signed response and refuses a standard registration whose token -response could exceed the shared client's 16 KiB read ceiling. - -Registry Relay validates the resulting token through its standard OAuth JWT verifier. It has no -Registry Mint-specific authentication branch. The Relay client library's private-key-JWT token -provider can acquire, retain, and refresh this token; see the -[Registry Relay client API](../../reference/client-api/#registry-relay). - -Keep `private-key-jwt` as the default when the client can sign assertions. For one managed QGIS -installation or another standard OAuth client that cannot, generate a secret and explicitly -select the compatibility profile: - -```sh -mint client-secret generate --out /run/registry-mint/secrets/qgis-west.secret -``` - -```yaml -clientId: qgis-west -principal: urn:example:managed-client:qgis-west -authorization: - scopes: - - registry:qgis:premises:read -clientAuthentication: - method: client-secret - secretFingerprints: - - sha256: -``` - -The command writes the raw 32-byte random credential only to a new owner-only file and prints -only its fingerprint. A secret registration cannot contain `keys`, Evidence authority, or -delegation. Use one registration per managed installation so its authority, rotation, and -revocation remain independent. Follow -[Use Registry Mint with QGIS and standard OAuth clients](../use-mint-with-qgis-and-standard-oauth-clients/) -for provisioning, QGIS configuration, renewal, and rotation. - -Validate the deployment before opening a socket: - -```sh -mint check --config /etc/mint/mint.yaml -``` - -`check` loads the configuration, governed public keys, signer, audit key, and client registry, then -performs the signer self-test before it exits. It -deliberately does not open the audit chain, which admits one writer at a time, so you can check -an edited configuration against the deployment it is about to replace. `check`, `serve`, and -`verify-audit` accept `MINT_CONFIG` in place of `--config`. - -## Start Registry Mint - -```sh -mint serve --config /etc/mint/mint.yaml -``` - -Onboarding, offboarding, and caller key rotation only need the client registry reloaded, not the -process restarted: send the running process `SIGHUP` and it re-reads `clients.directory`, -keeping the previous registry in place if the new one fails to load. - -## Obtain a token - -The preferred client path uses the `client_credentials` grant and `private_key_jwt` client -authentication (RFC 7523): it signs a short-lived JWT assertion with its own private key and -posts it to the token endpoint. - -```sh -curl -sS https://mint.example.org/token \ - -d grant_type=client_credentials \ - -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ - --data-urlencode "client_assertion=" -``` - -The assertion must carry `iss` and `sub` equal to the client id, `aud` equal to the configured -`clientAssertion.audience`, a unique `jti`, and `iat`/`exp` inside -`clientAssertion.maximumLifetimeSeconds`. Every `jti` is accepted once; presenting the same -assertion twice is refused. - -The `mint token` subcommand builds and sends that request for local testing. It is a client -tool: it signs with the caller's own key and never touches Registry Mint's signing key. - -```sh -mint token --url https://mint.example.org/token \ - --client-id permit-desk --key ./dev/permit-desk.jwk -``` - -It prints the access token alone on stdout, so `TOKEN=$(mint token ...)` is the whole usage. - -To build the same request from an application rather than a terminal, see -[Request an access token from your own code](../request-an-access-token/), which has worked -Python and TypeScript examples. - -## Verify the deployment - -Request a token and confirm the response shape: - -```sh -curl -sS https://mint.example.org/token \ - -d grant_type=client_credentials \ - -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \ - --data-urlencode "client_assertion=" -``` - -```json -{ - "access_token": "SYNTHETIC_FIXTURE_TOKEN", - "token_type": "Bearer", - "expires_in": 300 -} -``` - -This is the Evidence-profile response. A standard authorization response also carries `scope` -with the same space-delimited value Mint placed in the access token, for example -`"scope": "registry:business:read"`. - -Confirm the published key set resolves at the configured `signing.jwksPath` (default -`/.well-known/jwks.json`), and that `GET /ready` returns success once at least one client is -registered, the audit writer is healthy, and the signing provider passes its self-test. - -Verify the retained keyed chain with the same configuration and audit key: - -```sh -mint verify-audit --config /etc/mint/mint.yaml -``` - -The command reports the record count and keyed chain tail, and exits unsuccessfully if retained -records were corrupted or reordered. - -## Troubleshooting - -| Symptom | Cause | Fix | -| --- | --- | --- | -| The token request fails with `401 invalid_client` | Registry Mint collapses every client authentication failure, including an unknown client id, a method mismatch, a bad signature or secret, a replayed `jti`, or an expired assertion, into this one code so the endpoint cannot probe registrations. | Check the client id and selected method. For a private-key client, check the signing key, assertion lifetime, and unused `jti`. For a secret client, re-enter the generated per-installation secret. | -| The token request fails with `400 unsupported_grant_type` | `grant_type` is missing or is not exactly `client_credentials`. | Send `grant_type=client_credentials` in the form body. | -| `mint check` or `mint serve` refuses to start over signing | The active public key is not an ES256 P-256 JWK with its RFC 7638 `kid`, a revoked key is published, or the signer cannot prove it matches the governed active key. | Correct the governed key set and signer configuration. In strict mode, restore the local Transit proxy and its pinned key version. | -| `mint serve` refuses to start over audit | The audit key, directory, chain, or lock file is unsafe, another writer holds the chain, or retained records do not verify. | Check owner-only permissions, run one writer per `audit.path`, then run `mint verify-audit` before deciding whether recovery is needed. Do not discard the chain to make startup pass. | -| `mint check` refuses the configuration over audit | The audit hash key file is missing, is not owner-only, or is too short. `check` does not open the chain, so it never reports a running writer as a fault. | Restore the key file with owner-only permissions. Use `mint verify-audit` for the chain itself. | -| The token request fails with `500 server_error` and readiness changes to `503` | Mint could not durably append the audit decision and permanently poisoned the writer for this process. | Stop traffic, restore writable durable storage, preserve and verify the retained chain, then restart Mint. The failed request did not receive an access token. | -| Evidence Gateway rejects a token that Registry Mint minted | `accessTokens.claims` on Registry Mint and the resource server's own claim-name configuration name different claims for the same authority field. | Align every claim name (`principal`, `requesterTags`, `evidenceAudience`, `grantId`, `grantAuthority`, and `actor` where used) between the two configurations. | -| Registry Relay conceals a registered operation as `404 resource.not_found` | The Mint registration does not contain the selected operation or access-profile scope, or the token audience does not equal the Relay runtime audience. | Set `accessTokens.audiences` to the one Relay audience and register the exact compiled scope for this client. Do not copy scope from the request. | -| `GET /ready` returns `503` | No client is currently registered, the client registry failed to load, the audit writer is poisoned, or the signing provider is unavailable. | Check startup or reload diagnostics and audit storage. Add a valid client, restore and verify audit storage, or restore the Transit proxy and pinned version. Provider readiness recovers after a successful self-test. | - -## Next - -- [Author a Registry Relay project](../relay/) to declare the `access.scope` values a registered - client's `authorization.scopes` must match. -- [Configure Evidence Gateway](../evidence/) to align `accessTokens.claims` with the resource - server's own claim-name configuration. -- [Rotate credentials, keys, certificates, and trust](../../operate/advanced/rotate-credentials-and-trust/) for ongoing - rotation of Registry Mint's signing material and registered client keys. diff --git a/docs/site/src/content/docs/configure/relay.mdx b/docs/site/src/content/docs/configure/relay.mdx index f8028788d7..42e947ee56 100644 --- a/docs/site/src/content/docs/configure/relay.mdx +++ b/docs/site/src/content/docs/configure/relay.mdx @@ -191,8 +191,8 @@ operations: 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. [Configure Registry Mint](../mint/) to issue the access token that carries a scope such as -`registry:business:premises-search-registrar` when the deployment has no other identity provider. +list. Configure the deployment's OAuth issuer to issue an access token that carries a scope such as +`registry:business:premises-search-registrar`. The request is explicit: ```http @@ -345,8 +345,8 @@ different interpretation. ## Next -- [Configure Registry Mint](../mint/) to issue tokens for the scopes declared in this project's - access profiles. +- Configure the deployment's OAuth issuer to issue tokens for the scopes declared in this + project's access profiles. - [Operate Registry Relay](../../operate/relay/) to deploy the packaged revision. - [Semantics, classification, and disclosure in Relay](../../explanation/relay-semantics-and-disclosure/) for the concepts this authoring model builds on. diff --git a/docs/site/src/content/docs/configure/request-an-access-token.mdx b/docs/site/src/content/docs/configure/request-an-access-token.mdx index c522fee58b..280d3f76ac 100644 --- a/docs/site/src/content/docs/configure/request-an-access-token.mdx +++ b/docs/site/src/content/docs/configure/request-an-access-token.mdx @@ -1,256 +1,112 @@ --- title: Request an access token from your own code -description: Build and sign the client assertion Registry Mint expects, then exchange it for an access token, with worked Python and TypeScript examples. +description: Configure an issuer-neutral private key JWT client-credentials exchange with an explicit token endpoint, resource, and scopes. status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-03" +last_reviewed: "2026-09-13" doc_type: how-to locale: en standards_referenced: [] --- -Build the signed request Registry Mint expects when the caller is your own application rather -than the `mint token` command. - -This page covers Registry Mint's preferred `private_key_jwt` client-authentication method. For a -managed QGIS installation or another standard client that cannot sign assertions, use the -[client-secret compatibility profile](../use-mint-with-qgis-and-standard-oauth-clients/). - -## When to use this - -Use this page when you are integrating a registered client into an application and need an access -token to present to a resource server such as Evidence Gateway. If you only need a token at a terminal, -`mint token` already does all of this; see -[Obtain a token](../mint/#obtain-a-token). +Use a private key JWT client assertion when an application needs its own short-lived access token +for Evidence Gateway, Base Registry Engine, or Registry Relay. The deployment's OAuth issuer owns +client registration and token issuance. The resource server independently verifies the resulting +token against its configured issuer, audience, algorithm, token type, and authority claims. ## Before you start -You need four things, all of which come from whoever registered your client: - -- The `clientId` your client is registered under. -- Your client's **private** JWK. Registry Mint holds only the public half; if you do not have the - private half, nobody can issue you a token. -- The exact `clientAssertion.audience` value the deployment is configured with. This is not the - same as the audience of the token you get back, and confusing the two is the most common - first-attempt failure. -- The token endpoint URL. The path is always `/token` and is not configurable. - -Registry Mint serves plain HTTP and expects TLS termination it does not manage, so the URL you -call is the terminator's, not the process's own listener. - -## What you are signing - -One JWT, signed with your own private key, sent once. This is `private_key_jwt` client -authentication (RFC 7523) inside the `client_credentials` grant: there is no shared secret -anywhere in the exchange. - -| Claim | Value | Why it is checked | -| --- | --- | --- | -| `iss` | your `clientId` | Names which registration's keys to verify against. | -| `sub` | your `clientId`, identical to `iss` | Without it a legitimate client key could sign an assertion naming a different subject. | -| `aud` | the configured `clientAssertion.audience` | Stops a request built for one endpoint being replayed at another. | -| `jti` | a fresh unique value **per request** | Accepted exactly once. Reusing one is refused. | -| `iat` | now | Freshness, tolerating a small clock skew. | -| `exp` | within `clientAssertion.maximumLifetimeSeconds` of `iat` (default 300) | A long-lived assertion is a long-lived bearer credential, so the bound is enforced whatever you choose. | - -The header carries `alg` (one of the algorithms the deployment lists under -`clientAssertion.algorithms`), `typ: JWT`, and `kid` naming which of your registered keys signed -it. - -The examples below build the JWT from primitives rather than pulling in a JWT library, so that -every field above is visible in the code. A JWT library is a perfectly good substitute as long as -it lets you set `kid` and does not cache or reuse `jti`. - -## Python - -Needs `cryptography` and `requests`. - -```python -import base64 -import json -import time -import uuid - -import requests -from cryptography.hazmat.primitives.asymmetric import ed25519 - - -def b64url(raw: bytes) -> str: - return base64.urlsafe_b64encode(raw).decode().rstrip("=") - +Obtain these values from the issuer administrator: -def unb64url(text: str) -> bytes: - return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) +- The exact HTTPS token endpoint. +- The registered client identifier. +- The client's private JWK and registered key identifier. +- The client-assertion audience. This often equals the issuer identifier or token endpoint, but + the issuer contract decides the exact value. +- The RFC 8707 resource identifier for the intended resource server. +- The non-empty scopes approved for this client and resource. +Keep the private key in the calling process's secret store. The issuer registration holds only the +public key. Do not add a client secret as a fallback when assertion authentication fails. -def sign_client_assertion( - private_jwk: dict, - client_id: str, - audience: str, - lifetime_seconds: int = 120, -) -> str: - """Build one single-use assertion proving this client holds its own key.""" - now = int(time.time()) - header = {"alg": "EdDSA", "typ": "JWT", "kid": private_jwk["kid"]} - claims = { - "iss": client_id, - "sub": client_id, - "aud": audience, - "iat": now, - "exp": now + lifetime_seconds, - "jti": str(uuid.uuid4()), - } - signing_input = ".".join( - b64url(json.dumps(part, separators=(",", ":")).encode()) - for part in (header, claims) - ) - key = ed25519.Ed25519PrivateKey.from_private_bytes(unb64url(private_jwk["d"])) - return f"{signing_input}.{b64url(key.sign(signing_input.encode()))}" +## Configure a maintained client binding +The Evidence Gateway Node binding performs the exchange, refreshes before token expiry, coalesces +concurrent refreshes, and invalidates a cached token after an authentication refusal. Configure its +credential source explicitly: -def request_access_token(token_url: str, assertion: str) -> str: - response = requests.post( - token_url, - data={ - "grant_type": "client_credentials", - "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - "client_assertion": assertion, - }, - timeout=10, - ) - response.raise_for_status() - return response.json()["access_token"] +```typescript +const token = { + privateKeyJwt: { + tokenEndpoint: "https://issuer.example.org/oauth2/token", + clientId: "permit-desk", + clientKey: privateJwk, + audience: "https://issuer.example.org", + resource: "urn:registry:evidence", + scopes: ["evidence:evaluate"], + }, +}; ``` -Call it with the client's own private JWK: +The Python binding uses the same contract with snake-case field names: ```python -with open("client-signing-private-jwk", encoding="utf-8") as handle: - private_jwk = json.load(handle) - -assertion = sign_client_assertion( - private_jwk, - client_id="permit-desk", - audience="https://mint.example.org/token", -) -token = request_access_token("https://mint.example.org/token", assertion) -``` - -## TypeScript - -Needs no dependencies. `node:crypto` reads an Ed25519 JWK directly, and `fetch` is built in. - -```typescript -import { createPrivateKey, randomUUID, sign } from "node:crypto"; - -interface PrivateJwk { - kty: "OKP"; - crv: "Ed25519"; - kid: string; - d: string; - x: string; -} - -const b64url = (raw: Buffer | string): string => - Buffer.from(raw).toString("base64url"); - -/** Build one single-use assertion proving this client holds its own key. */ -export function signClientAssertion( - privateJwk: PrivateJwk, - clientId: string, - audience: string, - lifetimeSeconds = 120, -): string { - const now = Math.floor(Date.now() / 1000); - const header = { alg: "EdDSA", typ: "JWT", kid: privateJwk.kid }; - const claims = { - iss: clientId, - sub: clientId, - aud: audience, - iat: now, - exp: now + lifetimeSeconds, - jti: randomUUID(), - }; - const signingInput = [header, claims] - .map((part) => b64url(JSON.stringify(part))) - .join("."); - const key = createPrivateKey({ key: privateJwk, format: "jwk" }); - // Ed25519 signs the message itself, so the digest argument is null. - const signature = sign(null, Buffer.from(signingInput), key); - return `${signingInput}.${b64url(signature)}`; -} - -export async function requestAccessToken( - tokenUrl: string, - assertion: string, -): Promise { - const response = await fetch(tokenUrl, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "client_credentials", - client_assertion_type: - "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", - client_assertion: assertion, - }), - }); - if (!response.ok) { - const { error } = (await response.json()) as { error?: string }; - throw new Error(`token request failed: ${response.status} ${error ?? ""}`); - } - const body = (await response.json()) as { access_token: string }; - return body.access_token; +token = { + "private_key_jwt": { + "token_endpoint": "https://issuer.example.org/oauth2/token", + "client_id": "permit-desk", + "client_key": private_jwk, + "audience": "https://issuer.example.org", + "resource": "urn:registry:evidence", + "scopes": ["evidence:evaluate"], + } } ``` -## Use the token - -Present it as a bearer token to the resource server, not back to Registry Mint: +Pass this `token` value in the client configuration shown by +[request Evidence from an application](../../tutorials/request-evidence-from-an-application/). +The Relay and BReg client bindings expose the same private key JWT credential provider for their +own resource URLs and registered scopes. -```sh -curl -sS https://evidence.example.org/v1/evidence \ - -H "Authorization: Bearer ${TOKEN}" \ - -H 'content-type: application/json' \ - --data @request.json -``` +{/* Evidence: crates/registry-platform-httputil/src/client/private_key_jwt.rs; + crates/registry-evidence-client-node/client.d.ts; + crates/registry-evidence-client-py/src/convert.rs. */} -## Rules that bite +## Raw token request contract -**Sign a new assertion per request.** The `jti` is spent on first use and remembered past `exp`, -so a cached assertion fails the second time it is sent. What you may cache is the access token, -for the `expires_in` seconds the response reports. +If another client library performs the exchange, it must create a fresh signed JWT assertion for +each request. Its `iss` and `sub` are the registered client identifier, `aud` is the configured +assertion audience, `jti` is fresh, and `iat` and `exp` fit the issuer's accepted lifetime. The +protected header names the registered key with `kid` and uses the registered signing algorithm. -**The two audiences are different.** The assertion's `aud` is `clientAssertion.audience`, the -value that identifies the token endpoint. The resulting token's own `aud` comes from -`accessTokens.audiences` and identifies the resource server. Signing the assertion with the -resource server's audience is a refusal, not a warning. +Send the assertion in an OAuth form: -**You cannot ask for more authority than you are registered with.** Principal, requester tags, -evidence audience, and grant pair are all written from the client registry. Nothing you put in -the assertion changes them, so there is no scope parameter to send. +```text +grant_type=client_credentials +client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer +client_assertion= +resource=urn:registry:evidence +scope=evidence:evaluate +``` -**Every authentication failure looks identical.** An unknown client id, a bad signature, a -replayed `jti`, a wrong audience, and an expired assertion all return the same -`401 {"error": "invalid_client"}`. This is deliberate: the endpoint must not be usable to -discover which client ids are registered. It also means the response cannot tell you which of -those five things went wrong, so check them in order locally. +Treat the endpoint as HTTPS, disable redirects, apply a bounded timeout and response-size limit, +and never log the assertion, private key, access token, or token response. Keep `resource` and +`scope` immutable for one configured provider so a caller cannot redirect cached authority to a +different service. ## Troubleshooting -| Symptom | Likely cause | +| Symptom | Check | | --- | --- | -| `401 invalid_client` on the first attempt | The `aud` is the resource server rather than `clientAssertion.audience`, or `kid` names a key the registration does not carry. | -| `401 invalid_client` only on repeat requests | The assertion, rather than the token, is being cached and replayed. Generate a fresh `jti` each time. | -| `401 invalid_client` after a working period | The assertion's `exp` exceeds `clientAssertion.maximumLifetimeSeconds`, or the client clock has drifted beyond the tolerated skew. | -| `400 invalid_request` | A required form field is missing or duplicated, or `client_assertion_type` is not the exact `jwt-bearer` URN. A missing `grant_type` lands here too. | -| `400 unsupported_grant_type` | `grant_type` is present but is not exactly `client_credentials`. | -| The resource server rejects a token Registry Mint issued | The two deployments name different claims for the same authority field. See [How Evidence Gateway verifies these tokens](../../reference/mint/#how-evidence-gateway-verifies-these-tokens). | +| The issuer returns `invalid_client` | Confirm the client identifier, registered public key, `kid`, signing algorithm, assertion audience, clock, and fresh `jti`. | +| The issuer rejects the request shape | Confirm the exact token endpoint, `client_credentials` grant, assertion type URN, resource, and non-empty scopes. | +| The resource server rejects the token | Confirm the token issuer, audience or resource, token type, algorithm, scopes, and configured authority claim names. | +| A migrated configuration names `mint` | Replace the retired key with the owning component's `token_client` or private key JWT configuration and provide the explicit endpoint, audience, client, key, resource, and scopes. The retired key is rejected rather than aliased. | ## Next -- [How a client, Registry Mint, and Evidence Gateway interact](../../reference/mint/#how-a-client-registry-mint-and-evidence-gateway-interact) -- [Configure Registry Mint](../mint/) -- [Use Registry Mint with QGIS and standard OAuth clients](../use-mint-with-qgis-and-standard-oauth-clients/) -- [Registry Mint reference](../../reference/mint/) +- [Request Evidence from an application](../../tutorials/request-evidence-from-an-application/) +- [Configure Evidence Gateway](../evidence/) +- [Evidence Gateway security model](../../security/evidence/) diff --git a/docs/site/src/content/docs/configure/use-mint-with-qgis-and-standard-oauth-clients.mdx b/docs/site/src/content/docs/configure/use-mint-with-qgis-and-standard-oauth-clients.mdx deleted file mode 100644 index 5b5b34c59f..0000000000 --- a/docs/site/src/content/docs/configure/use-mint-with-qgis-and-standard-oauth-clients.mdx +++ /dev/null @@ -1,177 +0,0 @@ ---- -title: Use Registry Mint with QGIS and standard OAuth clients -description: Register one managed client installation to obtain renewable short-lived Relay access tokens from Registry Mint. -status: draft -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-08-13" -doc_type: how-to -persona: - - data publisher - - operator -locale: en -standards_referenced: [] ---- - -Register a managed QGIS installation or another standard OAuth client with Registry Mint, then let -the client obtain a new short-lived access token without an operator copying bearer tokens. - -## When to use this - -Use this compatibility profile for a managed client that supports the OAuth 2.0 Client Credentials -grant but cannot sign a `private_key_jwt` assertion. Register each installation separately so one -credential can be revoked without interrupting every client. - -This profile identifies the client installation, not the person using it. Use an institution identity -provider when Relay authorization must follow a person's login, role, or employment status. - -Registry Mint keeps `private_key_jwt` as the default authentication method. A registration enables -client-secret authentication only when its `clientAuthentication.method` is `client-secret`. - -## Before you start - -You need: - -- A Registry Mint deployment with one exact Relay audience and an access-token lifetime of 900 seconds - or less. -- A protected Relay access profile whose required scope is known. -- An owner-only location for the generated client secret. -- Transport Layer Security (TLS) endpoints for Registry Mint and Registry Relay. - -## Generate one installation secret - -Generate 32 random bytes, write the printable secret to an owner-only file, and print only its -SHA-256 fingerprint: - -```sh -mint client-secret generate --out "" -``` - -```text -sha256:<64-lowercase-hex-characters> -``` - -The command refuses to replace an existing file. The fingerprint is not a credential and belongs in -the client registration. Keep the generated file outside Git, deployment configuration, logs, and -shell history. - -## Register the client installation - -Create one file under `clients.directory`: - -```yaml -clientId: qgis-jeremi-laptop -principal: urn:example:managed-client:qgis-jeremi-laptop -authorization: - scopes: - - registry:qgis:premises:read -clientAuthentication: - method: client-secret - secretFingerprints: - - sha256: -``` - -Do not add `keys` to this registration. Client-secret and `private_key_jwt` authentication cannot be -combined in one client entry. Registry Mint reads the scope and principal from the registration; -the token request cannot add, replace, or widen either value. - -Check the deployment and reload the client registry: - -```sh -mint check --config /etc/mint/mint.yaml -kill -HUP "$(pgrep -x mint)" -``` - -The existing registry remains active when the edited registration fails to load. - -## Test the token exchange - -Ask curl to prompt for the secret so the value does not enter shell history: - -```sh -curl -sS --user qgis-jeremi-laptop \ - -d grant_type=client_credentials \ - https://mint.example.org/token -``` - -```text -Enter host password for user 'qgis-jeremi-laptop': -``` - -After you enter the generated value, Registry Mint returns a standard token response: - -```json -{ - "access_token": "", - "token_type": "Bearer", - "expires_in": 300, - "scope": "registry:qgis:premises:read" -} -``` - -Registry Mint does not issue a refresh token for Client Credentials. The client obtains another -short-lived access token with the same installation credential when the current token expires. -QGIS implements the [Client Credentials exchange as a form-body request](https://github.com/qgis/QGIS/blob/621ced71bd795bf3deb9a53505dabfca251a15d8/src/auth/oauth2/core/qgso2.cpp#L373-L395). -Its [OAuth2 network request hook](https://github.com/qgis/QGIS/blob/621ced71bd795bf3deb9a53505dabfca251a15d8/src/auth/oauth2/core/qgsauthoauth2method.cpp#L232-L305) -detects the expiring token, unlinks a session that has no refresh token, and links again before -applying authentication to the next layer request. For this grant, linking again performs another -Client Credentials exchange. - -## Configure QGIS - -1. Open **Settings > Options > Authentication**. -2. Set a QGIS master password if the authentication database is not initialized. -3. Add an authentication configuration named `Relay QGIS reader`. -4. Select **OAuth2** as the authentication method. -5. Select **Client Credentials** as the grant flow. -6. Set the token URL to `https://mint.example.org/token`. -7. Set the client ID to `qgis-jeremi-laptop`. -8. Enter the value from the owner-only client-secret file. -9. Leave scope empty. Registry Mint assigns the registered scope. -10. Select **Header** as the access method and save the configuration. -11. Create the OGC API Features connection with the Relay base URL and select - `Relay QGIS reader` as its authentication configuration. - -The QGIS project stores an authentication-configuration identifier. It does not need the client -secret or an access token in the project file. Protect the QGIS authentication database and its -master-password or operating-system keychain configuration as credentials for this installation. -See the official [QGIS authentication system overview](https://docs.qgis.org/latest/en/docs/user_manual/auth_system/auth_overview.html) -for its authentication-database and OAuth2 boundaries. - -## Rotate or revoke the installation - -Generate a second secret and add its fingerprint to `secretFingerprints`. At most two fingerprints -are accepted so an overlap cannot become an unbounded collection of old credentials. Reload Registry -Mint, update QGIS, remove the old fingerprint, and reload again. - -To revoke the installation, remove its registration and reload Registry Mint. New token requests fail -immediately. A token already issued remains valid until its bounded expiry, which is why the standard -authorization profile cannot exceed 900 seconds. - -## Verify renewal and revocation - -Leave the QGIS connection open through two access-token expiry boundaries and reload the layer after -each one. Both reloads must succeed without pasting a bearer token. - -Remove the client registration, reload Registry Mint, and reconnect after the last issued token -expires. QGIS can no longer discover or load the protected collection. Restore the registration only -if the installation remains authorized. - -## Troubleshooting - -| Symptom | Cause | Fix | -| --- | --- | --- | -| Registry Mint returns `invalid_client` | The client ID, secret, authentication method, or registration does not match. | Confirm the per-installation registration and enter the generated secret again. The response does not distinguish which credential fact failed. | -| Registry Mint refuses the registration | The fingerprint is malformed, `keys` is also present, or the authority profile is not standard authorization. | Use the exact generated `sha256:` value, omit `keys`, and configure `authorization`. | -| QGIS receives a token but Relay returns `404` | The registered scope does not authorize that concealed Relay resource. | Register the exact scope from the Relay access profile and reload Registry Mint. | -| QGIS works, then prompts after expiry | The OAuth configuration is not retaining the installation credential or cannot repeat Client Credentials. | Reopen the QGIS authentication configuration, confirm Client Credentials and Header access, then save it in the authentication database. | -| Every installation stops during rotation | Multiple installations shared one registration or the old fingerprint was removed before clients moved. | Give each installation a unique registration and keep no more than two fingerprints during a planned overlap. | - -## Next - -- [Configure Registry Mint](../mint/) for service signing, token policy, audit retention, and client - registry reloads. -- [Request an access token from your own code](../request-an-access-token/) when the client can use - the preferred `private_key_jwt` method. -- [Registry Mint reference](../../reference/mint/) for the complete registration and HTTP contract. diff --git a/docs/site/src/content/docs/explanation/architecture.mdx b/docs/site/src/content/docs/explanation/architecture.mdx index dd33e9443a..0d0809b53e 100644 --- a/docs/site/src/content/docs/explanation/architecture.mdx +++ b/docs/site/src/content/docs/explanation/architecture.mdx @@ -10,7 +10,6 @@ source_repos: - registry-relay - registry-evidence - registry-discovery - - registry-mint - solmara-lab last_reviewed: "2026-09-05" doc_type: explanation @@ -44,9 +43,6 @@ signed assertions. Registry Discovery serves one immutable index, built offline operator-approved list of public provider descriptions, and leaves endpoint trust and native invocation to the relying application. Registry Platform provides shared Rust primitives consumed by every runtime product. -Registry Mint is a supporting service rather than a seventh product: it issues the short-lived access -tokens a resource server such as Evidence Gateway or Base Registry Engine verifies, for deployments -that have callers and no identity provider. Solmara Lab is a separate adopter project built around synthetic registry data. It is not a formal Registry Stack product or a production integration reference. @@ -111,9 +107,8 @@ updated policy documents without touching deployment config. request or one reviewed SQL statement against a read-only SQLite extract. A compiled Base Registry Engine or Registry Relay route can be one of those fixed sources. Registry Discovery serves one immutable index built offline from approved provider descriptions, - and endpoint trust stays with the caller. Registry Mint is a supporting service issuing - the short-lived access tokens Evidence Gateway and Base Registry Engine verify. The - separate Solmara Lab adopter project provides synthetic registry data for + and endpoint trust stays with the caller. The separate Solmara Lab adopter project + provides synthetic registry data for demonstrations." /> @@ -260,32 +255,13 @@ decision. VC (`application/dc+sd-jwt`) under the frozen Version 1 profile. That serialization adds a response format, never a credential lifecycle: no issuance session, holder binding ceremony, status list, revocation, or presentation verification. -9. Where a deployment has callers and no identity provider, Registry Mint issues the short-lived - access tokens a resource server verifies. Both Evidence Gateway and Base Registry Engine are - such resource servers: a Base Registry Engine runtime file must name an OIDC issuer, an - audience, and an accepted algorithm, regardless of what any compiled access profile grants. A - compiled profile can still admit an anonymous caller to a public directory, but the compiler - refuses a profile that lets an anonymous caller reach a mutation; see - [how access works](../configuration-defined-registry/#how-access-works). The dependency - runs one way: `registry-mint` carries `registry-evidence` as a development dependency so its - tests drive Evidence Gateway's authenticator, and `registry-evidence` depends on no other - product crate. - {/* Evidence: authentication is a required, closed member of the Base Registry Engine runtime - file, crates/registry-breg/src/runtime_config.rs, RawAuthenticationConfig and - RawOidcVerifierConfig; crates/registry-breg/src/compiler.rs, "an anonymous access profile - cannot grant a mutation operation". */} -10. A consumer receives the returned assertion. The decision owner applies any - consumer-specific requirement, decision, workflow, or action rules outside Base Registry - Engine, Relay, and Evidence Gateway. -For what each project does and does not own, refer to the product overview pages linked in the capabilities table. {/* Evidence: response formats and the SD-JWT VC profile, products/evidence/OPERATOR-CONTRACT.md, "Response formats", and contract registry.evidence.sd-jwt-vc-profile/v1 in products/evidence/contracts/sd-jwt-vc-profile.yaml (frozen); CCCEV alignment, contract registry.evidence.cccev-mapping/v1 in products/evidence/contracts/cccev-field-mapping.yaml - (frozen); dependency direction, crates/registry-mint/Cargo.toml dev-dependencies and - crates/registry-evidence/Cargo.toml. */} + (frozen); dependency direction, crates/registry-evidence/Cargo.toml. */} ## Capabilities at a glance diff --git a/docs/site/src/content/docs/explanation/configuration-defined-registry.mdx b/docs/site/src/content/docs/explanation/configuration-defined-registry.mdx index 00d4d0fb70..5d18e40207 100644 --- a/docs/site/src/content/docs/explanation/configuration-defined-registry.mdx +++ b/docs/site/src/content/docs/explanation/configuration-defined-registry.mdx @@ -57,7 +57,7 @@ The compiler refuses field names that collide with envelope members, so `recordI ## How access works The server does not issue tokens. -It verifies bearer tokens from your identity provider, or from Registry Mint when you have none, and then selects an access profile. +It verifies bearer tokens from your identity provider and then selects an access profile. A request names the profile it wants with the `accessProfile` query option; without one, the default profile applies. A profile states the scopes, purposes, and principal claim a token must carry, and one grant per entity: @@ -74,9 +74,12 @@ Nothing in the runtime consults the identity provider for authorisation: the tok ### Local clients and their tokens -A local development session has no identity provider, so `bregctl dev` starts Registry Mint beside the registry and registers the callers declared in the project's `dev-clients.yaml`. +A local development session starts the maintained stock identity provider beside the registry and +registers the callers declared in the project's `dev-clients.yaml`. A client entry stands for one calling application. -It names the access profiles the journeys exercise with that client, the scopes Mint puts in its tokens, and the claims those tokens carry: the principal, the purpose, and any value a row boundary compares a field against. +It names the access profiles the journeys exercise with that client, the scopes the local issuer puts +in its tokens, and the claims those tokens carry: the principal, the purpose, and any value a row +boundary compares a field against. The two files divide the work. `dev-clients.yaml` shapes the token; `registry.yaml` decides what a token may do. @@ -161,7 +164,7 @@ Failed deliveries retry with backoff, and an operator can list and replay them. ## Where the product stops -- The server issues no tokens and stores no users. Identity and session handling belong to your identity provider or Registry Mint. +- The server issues no tokens and stores no users. Identity and session handling belong to your identity provider. - It renders no user interface. Clients talk to the REST API. - Review workflows are staged approvals over a proposal record, not a general workflow engine. - Spatial support is a bounded point-in-bounding-box profile over CRS84 points. The server publishes GeoJSON and claims no OGC API Features conformance. diff --git a/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx b/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx index 54058b5f75..293d2510ad 100644 --- a/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx +++ b/docs/site/src/content/docs/explanation/esignet-authentication-over-breg.mdx @@ -36,9 +36,9 @@ BReg owns the registry: the population records, the access profiles that decide read which fields, the revision history, and the audit journal. It knows nothing about eSignet as a product. From its side, the provider is one more client with one narrow grant. -Registry Mint issues the short-lived access token the provider presents to BReg. The provider -authenticates to Mint as a registered `private_key_jwt` client and never holds a long-lived registry -credential. +The provider obtains a short-lived access token from the OAuth issuer your deployment configures. +It authenticates as a registered `private_key_jwt` client and fixes the BReg resource and scopes in +its own configuration, so a login request cannot widen them. The provider itself is a Go module compiled into the eSignet host. eSignet selects it by setting the authentication provider to `breg` and pointing it at one YAML configuration file. The provider @@ -46,10 +46,6 @@ contributes three things: it verifies the citizen's challenge, it performs the g derives the claims and the pairwise subject that eSignet then signs. Everything else stays with eSignet. -{/* Evidence: crates/registry-mint/src/lib.rs, crates/registry-mint/src/assertion.rs, and -crates/registry-mint/src/clients.rs implement RFC 7523 private_key_jwt client authentication for -the client_credentials grant that the provider uses to obtain its BReg access token. */} - ## The login flow A login takes two round trips into your registry at most, and none until the citizen has proved @@ -60,14 +56,14 @@ sequenceDiagram participant C as Citizen participant E as eSignet participant P as BReg provider - participant M as Registry Mint + participant I as OAuth issuer participant B as BReg C->>E: Enter identifier, receive challenge C->>E: Submit challenge response E->>P: Verify challenge - P->>M: private_key_jwt client_credentials - M-->>P: Short-lived access token + P->>I: private_key_jwt client_credentials + I-->>P: Short-lived access token P->>B: Lookup account-check fields only B-->>P: uin, status E->>C: Consent screen (requested claims) @@ -82,7 +78,7 @@ sequenceDiagram The order matters for data protection: 1. **Challenge first.** The provider verifies the citizen's one-time code or other challenge before - any registry call. A wrong code never reaches BReg or Mint, so the registry is not an oracle for + any registry call. A wrong code never reaches BReg or the token issuer, so the registry is not an oracle for guessing identifiers. 2. **Account check with the smallest projection.** After a correct challenge, the provider looks up the identifier and asks BReg for only the fields named in `account_check_fields`, typically the @@ -112,7 +108,7 @@ body carrying the selector and the verified identifier. ```http POST /v1/records/population:lookup?accessProfile=esignet-source&$select=uin,status -Authorization: Bearer +Authorization: Bearer Content-Type: application/json Accept: application/json @@ -154,16 +150,14 @@ a code change. The provider needs: admits a request when every entry in required_scopes is present in the verified token, so an empty set admits any caller that satisfies the principal claim and the row boundaries. crates/registry-breg/src/access.rs raises access.profile.no_required_scope for that case. -crates/registry-mint/src/token.rs joins a client's registered scopes into the minted scope claim. */} +The provider configuration fixes the token resource and scopes for every exchange. */} -On the Mint side, register the provider as a `private_key_jwt` client with an ES256 or RS256 key, -and put the profile's required scope in that client's `authorization.scopes` and nowhere else. The -provider's token request carries no scope of its own, so the registration is the only thing that -lets it select the profile. Mint issues tokens the BReg access profile trusts, and the provider -caches each one for at most five minutes. +Register the provider with your OAuth issuer as a `private_key_jwt` client using an ES256 or RS256 +key. Configure the provider with the exact token endpoint, assertion audience, BReg resource, and +required scopes. The provider sends that immutable resource and scope set on every token request and +caches each returned token for at most five minutes. -The pages on [BReg access profiles](../../configure/breg-access/), -[Registry Mint](../../configure/mint/), and +The pages on [BReg access profiles](../../configure/breg-access/) and [governed registry actions](../governed-registry-actions/) explain the surrounding model. ## Claims and the pairwise subject @@ -185,7 +179,7 @@ rules shape it: An eSignet ID Token or UserInfo JWT authenticates a citizen to a relying party. It is not a registry credential. BReg and Evidence Gateway verify access tokens against their configured issuer and required token type, so forwarding a citizen's ID Token to either one is rejected. The only -credential that reaches the registry is the provider's own Mint access token. +credential that reaches the registry is the provider's own access token. {/* Evidence: crates/registry-breg/src/runtime_config.rs and crates/registry-evidence/src/auth.rs both build TokenVerifierConfig::access_token_profile, whose allowed_typ list is required and fails @@ -202,7 +196,7 @@ party or a citizen never learns whether an identifier exists, is inactive, or is | `registry_auth_invalid_request` | The request to the provider is malformed or the configuration rejects it. | | `registry_auth_challenge_failed` | The one-time code or other challenge did not verify. No registry call was made. | | `registry_auth_subject_denied` | The lookup resolved no record the profile may disclose. | -| `registry_auth_unavailable` | Mint or BReg did not answer within the configured timeout, returned an oversized body, redirected, or omitted a configured account-check field. | +| `registry_auth_unavailable` | The token issuer or BReg did not answer within the configured timeout, returned an oversized body, redirected, or omitted a configured account-check field. | | `registry_auth_context_invalid` | The authentication context does not match the relying party, client, or transaction. | | `registry_auth_context_expired` | The five-minute authentication context has lapsed. | @@ -226,10 +220,14 @@ breg: access_profile: esignet-source provisioned_fields: [uin, status, givenName, familyName, birthdate, gender] account_check_fields: [uin, status] -mint: - token_endpoint: https://mint.example.org/token +token_client: + token_endpoint: https://issuer.example.org/oauth2/token + assertion_audience: https://issuer.example.org/oauth2/token client_id: esignet-source private_key_file: /run/secrets/registry-client.jwk + key_id: esignet-source-2026-09 + resource: urn:breg:population + scopes: [registry.read, records.lookup] claim_map: sub: $psut given_name: givenName @@ -244,9 +242,13 @@ demo: ``` Secrets arrive as mounted files, never as inline values or environment variables. The pairwise -subject secret and the Mint client key are the two the deployment must protect and rotate on its +subject secret and the token client key are the two the deployment must protect and rotate on its own schedule. +The retired top-level `mint` key is rejected. Rename it to `token_client` and add the explicit +`assertion_audience`, `key_id`, `resource`, and nonempty `scopes` fields; there is no compatibility +alias and no client-secret mode. + :::caution[The demo verifier is for fixtures only] The `demo` block enables a synthetic one-time code verifier that reads codes from a static file. It exists for fixtures such as Solmara Lab. Production deployments supply a real challenge verifier @@ -286,10 +288,6 @@ separate registrystack/solmara-lab repository (docs/esignet.md) and cannot be an ## Next - [Configure BReg access profiles](../../configure/breg-access/) to define the lookup-only grant. -- [Run Registry Mint](../../configure/mint/) and register the provider as a `private_key_jwt` - client. -- [Request an access token](../../configure/request-an-access-token/) to test the permission before - wiring eSignet. - [BReg HTTP API reference](../../reference/breg-api/) for the lookup route and problem documents. - [Data minimization and purpose limitation](../data-minimization-and-purpose-limitation/) for the principles this integration applies. diff --git a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx index 32b65bbebc..50c1e59abc 100644 --- a/docs/site/src/content/docs/explanation/governed-registry-publication.mdx +++ b/docs/site/src/content/docs/explanation/governed-registry-publication.mdx @@ -162,7 +162,7 @@ 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. +Token issuance belongs to the deployment's identity provider rather than the Relay runtime. ## Next diff --git a/docs/site/src/content/docs/explanation/how-casework-works.mdx b/docs/site/src/content/docs/explanation/how-casework-works.mdx index 00c8083123..0a22f38380 100644 --- a/docs/site/src/content/docs/explanation/how-casework-works.mdx +++ b/docs/site/src/content/docs/explanation/how-casework-works.mdx @@ -256,7 +256,7 @@ A pending or uncertain attempt blocks that erasure until the attempt is recovere - Nothing is delivered outward. Casework records reminders and applies authored queue steps inside the item, and sends no message to a person or a system. - Casework serves an API and renders no interface of its own. The Registry App Kit is a separate project that provides a staff interface for BReg-backed work. - The API does not enable CORS, because a browser talks to its own host and that host calls Casework server to server on a private network. -- Casework issues no tokens and stores no users. Identity belongs to your OpenID Connect provider, or to Registry Mint when you have none. +- Casework issues no tokens and stores no users. Identity belongs to your OpenID Connect provider. - A source-neutral core and generic clients hold no registry protocol types, and no registry depends on Casework. {/* Evidence: products/casework/CHANGELOG.md; diff --git a/docs/site/src/content/docs/explanation/integration-patterns.mdx b/docs/site/src/content/docs/explanation/integration-patterns.mdx index 1c47eb2736..964b1fe46a 100644 --- a/docs/site/src/content/docs/explanation/integration-patterns.mdx +++ b/docs/site/src/content/docs/explanation/integration-patterns.mdx @@ -8,7 +8,6 @@ source_repos: - registry-manifest - registry-relay - registry-evidence - - registry-mint last_reviewed: "2026-08-21" doc_type: explanation locale: en @@ -48,8 +47,8 @@ Evidence Gateway answers one predefined requirement about one set of subjects wi assertion carrying the answer rather than the record. A requirement is what the tutorials call a question: the same object, written as `questions/.yaml` by an author and called a requirement once `evidencectl` has compiled it. -Registry Mint is a supporting service that issues the short-lived access tokens either surface -verifies when a deployment has no identity provider. +A deployment supplies a compatible OAuth issuer for the short-lived access tokens each protected +surface verifies. The patterns that follow describe Relay and Evidence Gateway. ## Country evidence mesh @@ -294,9 +293,8 @@ failure does not retry through sequential fanout. The caller's access token and the source credential serve different boundaries: -- The caller holds a short-lived access token for Evidence Gateway, from the deployment's identity - provider or from Registry Mint when there is none. See the - [Registry Mint reference](../../reference/mint/). +- The caller holds a short-lived access token for Evidence Gateway from the deployment's configured + OAuth issuer. - Where the authoritative source is an HTTP JSON one, Evidence Gateway holds its own compiled credential for it, in every bundle a production or evidence-grade deployment may load. The caller never sees it and cannot influence which credential is resolved. A statement source holds no diff --git a/docs/site/src/content/docs/explanation/known-limitations.mdx b/docs/site/src/content/docs/explanation/known-limitations.mdx index 442fcd97c8..20d56fd411 100644 --- a/docs/site/src/content/docs/explanation/known-limitations.mdx +++ b/docs/site/src/content/docs/explanation/known-limitations.mdx @@ -8,7 +8,6 @@ source_repos: - registry-relay - registry-manifest - registry-evidence - - registry-mint - solmara-lab last_reviewed: "2026-08-11" doc_type: explanation @@ -278,33 +277,6 @@ assertions, and no caller input selects a mode. and independent presentation verification. Its opt-in upstream half runs focused source tests at exact Inji revisions, not Android or iOS user-interface or device automation. -## Registry Mint limits - -Registry Mint issues the short-lived access tokens a resource server such as Evidence Gateway verifies, -for deployments with no identity provider. A deployment that already has an identity provider does -not run it. Read the full surface in the [Registry Mint reference](../../reference/mint/). - -- Machine clients only: the token endpoint supports only the `client_credentials` grant. - `private_key_jwt` remains the registration default; a standard authorization client may - explicitly select `client_secret_basic` or `client_secret_post` compatibility. There is no - authorization-code flow, user-facing login, refresh token, or consent surface. A client secret - identifies one managed installation, not the person using it. -- No revocation and no introspection: the service exposes authorization-server metadata, a token - endpoint, a JWKS endpoint, and health and readiness probes. A minted token is accepted by its - audience until it expires, so token lifetime inside the `60..=3600` second bound is the only - containment lever. A deployment with standard authorization clients has a stricter 900-second - ceiling. -- Authority is registry-owned: the authority a token asserts is written from the client's registry - entry, never from the request, and a client registration whose subject claim path would overwrite - an authority claim refuses to load. -- Client-authentication failures are opaque by design: they collapse to one `401 invalid_client` - response, so the endpoint is not an oracle for which part of the assertion failed. -- The client registry is a watched directory: entries are reloaded on `SIGHUP` without a restart, - so any file written into that directory registers a client and the authority Registry Mint will - assert for it. Treat the directory as key material. -- Transport is the operator's: Registry Mint serves plain HTTP and expects TLS termination it does - not manage. - ## Stack-wide boundaries at a glance These boundaries cut across components. diff --git a/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx b/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx index aee71e5d39..3a90b902aa 100644 --- a/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx +++ b/docs/site/src/content/docs/explanation/registry-modeling-patterns.mdx @@ -753,7 +753,7 @@ accessProfiles: filterableFields: [inspection] ``` -{/* Evidence: crates/registry-breg/src/contract.rs, AccessProfileSource and AccessGrantSource; +{/* Evidence: crates/registry-breg/src/contract.rs, AccessProfileSource and AccessPermissionSource; crates/registry-breg/src/access.rs, explain_access(); crates/registry-breg/src/compiler.rs. */} diff --git a/docs/site/src/content/docs/explanation/threat-model.mdx b/docs/site/src/content/docs/explanation/threat-model.mdx index b110fd150d..55a5f20f9e 100644 --- a/docs/site/src/content/docs/explanation/threat-model.mdx +++ b/docs/site/src/content/docs/explanation/threat-model.mdx @@ -7,7 +7,6 @@ source_repos: - registry-stack - registry-relay - registry-evidence - - registry-mint - solmara-lab last_reviewed: "2026-08-21" doc_type: explanation @@ -63,9 +62,6 @@ beside them. A separate adopter demo sits outside the production trust boundary: - Evidence Gateway: a minimum-disclosure assertion service. It answers one bounded, predefined question about one subject with a signed assertion carrying the answer and not the record. - Registry Platform: the shared security primitives that the runtime services build on. -- Registry Mint: a supporting service, not a pattern of its own. It issues the short-lived - access tokens a resource server such as Evidence Gateway verifies, for a deployment with no - identity provider. The dependency runs one way: Evidence Gateway does not depend on Registry Mint. - Solmara Lab: a separate adopter demo, running on synthetic data and demo configuration. Treat Solmara Lab as out of scope for production trust. Its demo and template integrations are integration examples, not a production freshness or replay-protection profile, and a @@ -84,8 +80,8 @@ auditing separately. {/* SVG diagram. Every boundary label is restated in the sections that follow. */}
The Registry Stack trust boundaries in one map. One request path runs from Registry Mint,
-            which issues a short-lived access token, to the caller, to Evidence Gateway, and then to
+       alt= Four formal Registry Stack products, with Registry Mint as a supporting service and the
-            separate Solmara Lab adopter demo. Registry Platform provides shared auth, audit, HTTP,
+       alt=
@@ -65,7 +60,6 @@ For how the products connect at runtime and how Solmara Lab demonstrates them, s | How is that registry contract authored, checked, and sealed into a package before it is served? | [`relayctl`](../../reference/relayctl/) | | How is `metadata.yaml` validated or rendered into standards-facing artifacts? | [Registry Manifest](https://github.com/registrystack/registry-stack) | | How is one bounded question about one subject answered with a signed assertion that carries the answer and not the record? | [Evidence Gateway](../../products/registry-evidence/) | -| How does a deployment with callers and no identity provider issue access tokens? | [Registry Mint](../../reference/mint/) | | How do the services run together in a local demo? | [Solmara Lab](https://github.com/registrystack/solmara-lab/blob/3d5c492ea50c6fdcefd5978df6f036422096421c/README.md) | Cross-project explanation, standards evidence, and contract indexes live in these docs (not in any one project repo). @@ -208,8 +202,7 @@ answer rather than the record. It does not own: {/* Evidence: crates/registry-evidence/Cargo.toml depends on registry-platform-audit, -crypto, -httpsec, -httputil, -oidc, and -sdjwt, and on no other product crate. */} - Token issuance and identity proofing. Evidence Gateway verifies access tokens under one reviewed OIDC - profile with exactly one trusted issuer; it issues none. Registry Mint issues them for a - deployment with no identity provider. + profile with exactly one trusted issuer; it issues none. {/* Evidence: "Supported deployment", products/evidence/OPERATOR-CONTRACT.md. */} - Consumer decisions. Requirements, eligibility, ranking, approval, routing, payment, workflow, and action policy stay with the accountable decision owner. Evidence Gateway is not a workflow, orchestration, @@ -227,23 +220,6 @@ bundle and the one matched authority grant both name that format, under the froz `products/evidence/contracts/sd-jwt-vc-profile.yaml`. That profile adds a response format, never a credential lifecycle. -## Registry Mint - -Registry Mint issues short-lived access tokens to registered machine clients so a deployment with no -identity provider still has one. It does not own: - -- End-user authentication, identity proofing, or consent. Registry Mint authenticates registered - machine clients through `private_key_jwt` by default or an explicitly selected standard - client-secret compatibility method, not people. A secret identifies the managed installation. -- Caller-declared authority. Token authority is written from the client registry and never from the - caller's own request. -- Evidence Gateway authorization. Evidence Gateway independently verifies the token and re-authorizes every request - against its own bundle and authority grants. - -{/* Evidence: contract status and stable properties, docs/site/src/content/docs/reference/mint.mdx, - sourced from crates/registry-mint/src/clients.rs and crates/registry-mint/src/token.rs; - one-way dependency, crates/registry-mint/Cargo.toml dev-dependencies. */} - ## Solmara Lab Solmara Lab is a separately maintained adopter demo for the fictional Republic of Solmara. diff --git a/docs/site/src/content/docs/operate/advanced/index.mdx b/docs/site/src/content/docs/operate/advanced/index.mdx index f200fa447d..c08e6dd1dd 100644 --- a/docs/site/src/content/docs/operate/advanced/index.mdx +++ b/docs/site/src/content/docs/operate/advanced/index.mdx @@ -6,7 +6,6 @@ owner: registry-docs source_repos: - registry-stack - registry-evidence - - registry-mint - registry-platform last_reviewed: "2026-08-11" doc_type: reference diff --git a/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx b/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx index 10add4b180..6bab7d578f 100644 --- a/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx +++ b/docs/site/src/content/docs/operate/advanced/inspect-and-diagnose.mdx @@ -1,13 +1,12 @@ --- title: Inspect and diagnose a running deployment -description: Inspect Relay, Evidence Gateway, and Registry Mint runtime health, readiness, audit, and API shape, then classify failures with stable diagnostics. +description: Inspect Relay and Evidence Gateway runtime health, readiness, audit, and API shape, then classify failures with stable diagnostics. status: current owner: registry-docs source_repos: - registry-stack - registry-relay - registry-evidence - - registry-mint - registry-platform last_reviewed: "2026-08-11" doc_type: how-to @@ -15,7 +14,7 @@ locale: en standards_referenced: [] --- -Use this procedure to inspect a running Relay, Evidence Gateway, or Registry Mint instance and +Use this procedure to inspect a running Relay or Evidence Gateway instance and diagnose source or startup failure, caller authorization failure, format mismatch, cursor error, ambiguity, or audit-write failure without collecting source rows or secret values. @@ -24,7 +23,7 @@ ambiguity, or audit-write failure without collecting source rows or secret value - Use the protected operator network and a least-privilege posture or API credential. - Know the active product and instance for Relay: its runtime file path, bound source path, and package `packageRevision` digest. Know the deployment revision for Evidence Gateway and - Registry Mint. + Evidence Gateway. - Keep public problem responses, operator diagnostics, and protected audit records in separate access classes. - Reproduce with synthetic identifiers unless separate authority permits another probe. @@ -34,10 +33,9 @@ ambiguity, or audit-write failure without collecting source rows or secret value Public problem details give callers stable, minimized classifications; Relay's `404 resource.not_found` concealment for both an unknown and an unauthorized access profile is one instance of that discipline. -Relay, Evidence Gateway, and Registry Mint each write their own audit or operational records and keep -their own keys separate: Relay's audit sink, Evidence Gateway's keyed JSONL audit -chain (`products/evidence/OPERATOR-CONTRACT.md`, Audit and operational data), and Registry Mint's -keyed JSONL token-decision chain. +Relay and Evidence Gateway each write their own audit or operational records and keep their own keys +separate: Relay's audit sink and Evidence Gateway's keyed JSONL audit chain +(`products/evidence/OPERATOR-CONTRACT.md`, Audit and operational data). No deployment exposes a cross-product view: each product reports only its own health, readiness, and audit chain. Relay V2 has no separate operator-facing posture or admin surface beyond @@ -55,9 +53,9 @@ Inspect each surface for its own purpose: | Surface | Use | Boundary | | --- | --- | --- | -| `GET /health` (Relay, Evidence Gateway, Registry Mint) | Process liveness | Does not check all dependencies or data freshness; Evidence Gateway's `/health` answers 200 even when a source credential is missing | -| `GET /ready` | Current traffic-admission readiness | Does not prove backup freshness or country approval; Relay's `/ready` ANDs audit-sink readiness, bound SQLite source readiness, and OIDC issuer readiness when an issuer is configured; Evidence Gateway checks its signing, audit, subject-binding, and source credentials, while Mint checks that clients exist, its audit writer is healthy, and its signing provider is available | -| Product audit records | Security and request evidence | Retained chain integrity does not prove complete off-host receipt; use `evidence verify-audit` for Evidence Gateway and `mint verify-audit` for Mint. Relay V2 has no equivalent offline verification command; inspect the sealed segment sequence directly | +| `GET /health` (Relay and Evidence Gateway) | Process liveness | Does not check all dependencies or data freshness; Evidence Gateway's `/health` answers 200 even when a source credential is missing | +| `GET /ready` | Current traffic-admission readiness | Does not prove backup freshness or country approval; Relay's `/ready` ANDs audit-sink readiness, bound SQLite source readiness, and OIDC issuer readiness when an issuer is configured; Evidence Gateway checks its signing, audit, subject-binding, and source credentials | +| Product audit records | Security and request evidence | Retained chain integrity does not prove complete off-host receipt; use `evidence verify-audit` for Evidence Gateway. Relay V2 has no equivalent offline verification command; inspect the sealed segment sequence directly | | `GET /openapi.json` | Concrete API shape for the running instance | Relay's document reflects the operations compiled into the running package and needs no credential; Evidence Gateway's is the released generated artifact, unauthenticated and independent of the deployed bundle | | Evidence Gateway `GET /metrics` (only when `metricsListener` is configured) | Request-boundary counts and latency by route, method, status, and problem code | Absent unless configured; never serves evidence routes and reveals no request content | @@ -71,8 +69,6 @@ curl -fsS https:///health curl -fsS https:///ready curl -fsS https:///health curl -fsS https:///ready -curl -fsS https:///health -curl -fsS https:///ready ``` Fetch a running Relay instance's `/openapi.json` directly and compare it against the package's @@ -254,7 +250,6 @@ Retain: response header, and the output of `evidence verify-audit` when a signing or audit failure is suspected. The public trace is not the internal audit operation identifier. -- Registry Mint's operation identifier from its local log and the output of `mint verify-audit` when a token-decision audit failure is suspected. Do not retain the assertion or access token. - Redacted audit correlation ids and the tested synthetic canary outcome. - The traffic-admission or escalation decision. diff --git a/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx b/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx index 66bef399d7..f92fac42e0 100644 --- a/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx +++ b/docs/site/src/content/docs/operate/advanced/rotate-credentials-and-trust.mdx @@ -1,13 +1,12 @@ --- title: Rotate credentials, keys, certificates, and trust -description: Rotate Registry Relay, Evidence Gateway, and Registry Mint credentials, signing keys, and trust material without exposing secret material or widening authority. +description: Rotate Registry Relay and Evidence Gateway credentials, signing keys, and trust material without exposing secret material or widening authority. status: current owner: registry-docs source_repos: - registry-stack - registry-relay - registry-evidence - - registry-mint - registry-platform last_reviewed: "2026-08-11" doc_type: how-to @@ -16,7 +15,7 @@ standards_referenced: [] --- Use this procedure to rotate a Relay audit-integrity key, cursor-integrity key, or bound source -path, an Evidence Gateway signing key, or a Registry Mint signing key or client credential, without +path or an Evidence Gateway signing key, without exposing secret material and without widening authority. Relay V2 has no caller-key management and no configuration-signing or trust-anchor system of its own; the sections below explain why, and where that authority actually lives. @@ -25,9 +24,9 @@ where that authority actually lives. - Identify the material, every consumer, its current secret or trust reference, and its expiry. - Preserve a verified recovery set for the current Relay product configuration, and keep the - prior Evidence Gateway bundle revision and Registry Mint configuration file each rotation replaces. + prior Evidence Gateway bundle revision each rotation replaces. - Keep caller traffic outside the staged Relay instance until its checks pass; restart Evidence Gateway - and Registry Mint only after their own offline checks pass first. + only after its offline checks pass first. - Use synthetic or institution-approved canaries. Do not use personal data for a rotation probe. ## Ownership and trust boundary @@ -41,10 +40,6 @@ Evidence Gateway owns its governed public signing keys, Transit signer binding, and subject-binding secret. The HMAC secrets resolve through its owner-only secret root, while the production private signing key remains in Transit (`products/evidence/OPERATOR-CONTRACT.md`, Secrets and keys). -Registry Mint owns its governed public signing keys, Transit signer binding, audit hash secret, and -client registry. Each registered client has one selected authentication method and one granted -authority. Private-key clients register one or more public keys; explicitly compatible standard -clients register one or two client-secret fingerprints. The deployment operator owns secret storage, certificates, the OIDC issuer Relay trusts, traffic admission, and revocation. @@ -53,15 +48,13 @@ identified by an integrity digest, `packageRevision`; `relay serve` re-derives t its inputs and refuses to start on a mismatch. There is no signing key, no anchor, no lane, and no anti-rollback ratchet to rotate for Relay V2, unlike Relay V1's `registryctl`-built and `registryctl`-verified `relay-public` and `relay-consultation` lanes. -Evidence Gateway and Registry Mint have no `registryctl`-governed bundle or trust-anchor system of their -own, and never did: Evidence Gateway validates its governed bundle and runtime file with `evidence check`, -and Registry Mint validates its configuration with `mint check`. +Evidence Gateway has no `registryctl`-governed bundle or trust-anchor system of its own and never +did: Evidence Gateway validates its governed bundle and runtime file with `evidence check`. {/* Evidence: crates/registry-relay-v2/src/contract.rs, crates/registry-relay-v2/src/startup.rs, crates/registry-relayctl/src/lib.rs, products/relay-v2/CONCEPT.md, products/evidence/OPERATOR-CONTRACT.md, - docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx, and - docs/site/src/content/docs/configure/mint.mdx. */} + docs/site/src/content/docs/tutorials/rotate-evidence-signing-keys.mdx. */} ## Classify the rotation @@ -77,9 +70,8 @@ and Registry Mint validates its configuration with `mint check`. For Relay, a governed change, a new source path, a new audit- or cursor-integrity key reference, or a new OIDC issuer binding, means authoring a new package with `relayctl package` and a new `runtime.yaml`, then replacing the complete running revision; there is no separate - signing step. For Evidence Gateway and Registry Mint, a governed change edits the bundle or - configuration file directly, then runs `evidence check` or `mint check` before the next - restart. + signing step. For Evidence Gateway, a governed change edits the bundle and runtime file, then runs + `evidence check` before the next restart. [Operate Relay](../../relay/) defines how Relay V2 binds deployment secrets and replaces complete revisions; [Relay project authoring](../../../configure/relay/) defines the `relayctl` commands @@ -129,37 +121,6 @@ Gateway's `authentication.revokedKeyIds`; a compromised caller credential must b issuer, and Relay only needs a new deployment when the issuer binding itself changes (`id`, `discoveryUrl`, `audience`, `tokenTypes`, or `algorithms`). -For a Registry Mint client key, add or update the client's registration file under -`clients.directory` with the new public JWK, keep the old key in the registration during the -overlap window, then send the running Registry Mint process `SIGHUP` to reload the client registry -without a restart (`docs/site/src/content/docs/configure/mint.mdx`). -Registry Mint reloads the whole client registry atomically: a malformed replacement file fails the -reload and Registry Mint keeps serving the previous registry rather than a partial one. -Remove the old key only after every client has moved to the new key and the configured -maximum client-assertion lifetime plus 30 seconds has elapsed. A compromised client key is removed -immediately and reloaded without an overlap window. - -For an explicitly compatible client secret, generate a replacement with -`mint client-secret generate`, add its fingerprint beside the old fingerprint, reload, update that -one managed installation, remove the old fingerprint, and reload again. At most two fingerprints -are accepted. Remove the compromised fingerprint immediately when another valid fingerprint -remains. Remove the complete registration when revoking the last or only secret, then reload; -tokens already issued remain valid until expiry. - -Rotating Registry Mint's own signing key is a configuration change, not a client-registry reload: -publish the next public JWK, then deploy and restart every replica so all of them publish the -overlap set. In a second candidate, switch `signing.activePublicJwkFile` and the pinned Transit key -version together, keep the old public JWK in `signing.publishedPublicJwkFiles`, then deploy and -restart every replica again. Raise the Transit key's `min_encryption_version` and remove the old -public JWK only after the maximum token lifetime plus consumer skew has elapsed. - -Evidence Gateway does not need reconfiguration for a planned Registry Mint rotation. It fetches Registry Mint's -JWKS from the configured `jwksUri` on its own cache lifecycle, provided both services already agree -on `issuer` and `accessTokens.claims`. For a compromised Mint service key, add its thumbprint to -Evidence Gateway `authentication.revokedKeyIds` in the same incident rollout and restart every -affected consumer. The denylist takes precedence over a cached JWKS -(`products/evidence/OPERATOR-CONTRACT.md`, Startup and readiness). - ## Configuration signing and trust anchors do not apply to Relay V2 Relay V1 built and verified two independently signed lanes, `relay-public` and @@ -174,7 +135,7 @@ invoke for Relay V2: replacing a package is the same deployment change, and the only integrity check is the automatic startup re-derivation every `relay serve` performs. -## Rotate Evidence Gateway and Registry Mint signing material +## Rotate Evidence Gateway signing material Evidence Gateway's signing key rotation is a rehearsed procedure, not new material for this page: create the replacement non-exportable P-256 Transit key version, publish its public JWK, then @@ -186,9 +147,6 @@ binding contract behind it. Missing or failed signing is fail-closed: a rotation mistake surfaces as refused requests, never as an unsigned assertion. -Registry Mint's signing-key rotation is covered above, under Rotate caller keys, next to the -client-key rotation it is usually done alongside. - Audit hash-secret rotation is a separate event from signing-key rotation. It begins a new epoch: drain and stop, verify and record the old head, archive the old runtime, master, segments, and head, then use a fresh path, a fresh master, and incremented `hashKeyVersion`. Retain the old master under @@ -201,7 +159,6 @@ Retain: - The `relayctl check` (and `relayctl check --production`) report for a Relay V2 project change, and the package's `packageRevision` digest for the staged candidate. - The `evidence check` output and the readiness result for a rotated Evidence Gateway signing key. -- The `mint check` output for a rotated Registry Mint signing key or client registration. - Health and readiness results from the staged Relay instance; Relay V2 has no redacted posture report to retain alongside them. - A synthetic or authorized bounded canary result. @@ -219,9 +176,8 @@ deployed runtime binding is correct. `relay serve`'s startup re-derivation prove installed package's bytes match what `relayctl package` produced from the reviewed inputs; there is no separate operator-invoked bundle-verification command the way Relay V1's `registryctl trust bundle verify` was. -`evidence check` and `mint check` prove their own deployment's key material and configuration are -internally consistent before a restart; neither proves the other product's trust lineage, because -Relay, Evidence Gateway, and Registry Mint share no signing or activation system. +`evidence check` proves Evidence Gateway's key material and configuration are internally consistent +before a restart. Relay and Evidence Gateway share no signing or activation system. Staged readiness and a bounded canary prove the tested runtime path. These gates do not prove country approval, legal authority, every source operation, every caller @@ -237,10 +193,6 @@ complete revision again, the same procedure as any other rollback. Keep old Evidence Gateway public verification keys published in the JWKS while assertions signed by those keys can still be verified: at least the maximum assertion validity plus allowed clock skew (`products/evidence/OPERATOR-CONTRACT.md`, Secrets and keys). -Keep Registry Mint staged and Evidence Gateway's traffic blocked when Registry Mint's issuer, audience, or -claim names do not exactly match Evidence Gateway's configured authentication profile: a mismatch fails -closed as `auth.invalid_credential` rather than granting the wrong authority -(`docs/site/src/content/docs/configure/mint.mdx`, Troubleshooting). ## Escalate @@ -254,5 +206,4 @@ live country data. - [Relay project authoring](../../../configure/relay/) - [Operate Relay](../../relay/) - [Inspect and diagnose a running deployment](../inspect-and-diagnose/) -- [Configure Transit signing for Evidence Gateway and Registry Mint](../../../tutorials/move-evidence-to-production-signing/) -- [Configure Registry Mint](../../../configure/mint/) +- [Configure Transit signing for Evidence Gateway](../../../tutorials/move-evidence-to-production-signing/) diff --git a/docs/site/src/content/docs/operate/breg-requirements.mdx b/docs/site/src/content/docs/operate/breg-requirements.mdx index ca48adcc12..4f81f953c4 100644 --- a/docs/site/src/content/docs/operate/breg-requirements.mdx +++ b/docs/site/src/content/docs/operate/breg-requirements.mdx @@ -29,8 +29,7 @@ on your machine. - **HTTPS in front of it.** The process trusts no host or forwarded header; your reverse proxy or ingress terminates TLS, and generated links use the public origin you configure. - **A token issuer.** BReg verifies bearer tokens from one OpenID Connect issuer and issues none. - Your identity provider fills this role, or [Registry Mint](../../configure/mint/) does when you - have none. + Configure a compatible OAuth issuer for this role. - **A signer who is not the author.** A production package is published only after an external signer produces a detached Ed25519 signature over it, and the runtime accepts a package only when enough of the keys in its trust anchor have signed. Who holds those keys, and how they sign, is @@ -61,9 +60,8 @@ with no database open, so an author can work on a laptop until the project is re ## How it ships Each release publishes the `breg` runtime and the `bregctl` authoring tool as reproducible -binaries, with an installer that verifies them against the release checksums and installs them, and -the Registry Mint token issuer beside them, together or not at all. Keep the pair on one release: -both compute the schema fingerprint that a test receipt binds. Each release also publishes a +binaries, with an installer that verifies them against the release checksums and installs them +together or not at all. Each release also publishes a container image built on a distroless base with no shell, running as a non-root user, that holds the runtime binary and nothing else; configuration, package, trust anchor, and secrets mount read-only into it. diff --git a/docs/site/src/content/docs/operate/breg.mdx b/docs/site/src/content/docs/operate/breg.mdx index 66baa268d4..244fd9115f 100644 --- a/docs/site/src/content/docs/operate/breg.mdx +++ b/docs/site/src/content/docs/operate/breg.mdx @@ -87,9 +87,7 @@ bregctl 0.26.1 The installer accepts the platforms in [platform support](../../explanation/known-limitations/#platform-support), and refuses any other platform rather than guessing. It verifies every downloaded binary against the release `SHA256SUMS` -before anything reaches the install directory, and installs `breg`, `bregctl`, and `mint` together -or not at all. Registry Mint comes along because a local registry uses it when no identity provider -is at hand; a production deployment with its own OpenID Connect issuer can ignore it. It does not verify +before anything reaches the install directory, and installs `breg` and `bregctl` together or not at all. It does not verify release authenticity. The signed checksum chain that does, and the checks behind it, are recorded in [OpenSSF and release trust](../../security/openssf-evidence/). Replace `| bash` with `| less` to read the installer before you run it on a host you operate. For a higher-assurance installation, follow @@ -400,8 +398,8 @@ Use the same principal claim in an ownership row boundary when the record's owne Use separate assignment claims for district, tenant, or team membership. Configure the issuer to derive these claims from trusted assignments rather than caller-supplied values. -Deployments without an identity provider can run [Registry Mint](../../configure/mint/) as the issuer for registered machine clients. -Mint's client-credentials flow represents a service, not an individual signing in. +Configure a compatible OAuth issuer for registered machine clients. A client-credentials flow +represents a service, not an individual signing in. Human login, account lifecycle, and multifactor authentication belong to your identity provider and application. When replacing an issuer, verify the same permitted and refused requests with real tokens before changing the serving configuration. Compare issuer, resource audience, token headers, permission source, principal identity, purpose, and assignment claim shapes. @@ -409,7 +407,6 @@ Compare issuer, resource audience, token headers, permission source, principal i {/* Evidence: crates/registry-breg/src/auth.rs; crates/registry-breg/tests/http_auth.rs; crates/registry-breg/src/runtime_config.rs; - crates/registry-mint/src/lib.rs; crates/registry-platform-oidc/src/lib.rs. */} ### Rotate signing keys and handle issuer outages @@ -766,5 +763,5 @@ behind an upstream rate limit. - [Evidence deployment targets](https://github.com/registrystack/registry-stack/tree/v0.26.1/products/evidence/reference/deployment-targets) and the [Evidence Compose adapter](https://github.com/registrystack/registry-stack/blob/v0.26.1/docker/compose/docker-compose.yaml) - cover Evidence and Registry Mint deployment shapes, not Base Registry Engine; read them when this - deployment also runs Evidence or Mint alongside the registry. + cover Evidence deployment shapes, not Base Registry Engine; read them when this deployment also + runs Evidence alongside the registry. diff --git a/docs/site/src/content/docs/operate/casework.mdx b/docs/site/src/content/docs/operate/casework.mdx index 0c6a623a84..e47d7c981b 100644 --- a/docs/site/src/content/docs/operate/casework.mdx +++ b/docs/site/src/content/docs/operate/casework.mdx @@ -72,9 +72,8 @@ caseworkctl --version ``` The installer verifies every downloaded binary against the release `SHA256SUMS` before anything -reaches the install directory, and installs `casework`, `caseworkctl`, and `mint` together or not -at all. Registry Mint comes along because a deployment with no identity provider uses it to issue -access tokens; a deployment with its own OpenID Connect issuer can ignore it. The installer does +reaches the install directory, and installs `casework` and `caseworkctl` together or not at all. +The installer does not verify release authenticity. Replace `| bash` with `| less` to read it before running it on a host you operate. `CASEWORK_INSTALL_DIR` selects the install directory, and the default is `~/.local/bin`; `CASEWORK_VERSION` pins one release; `CASEWORK_ASSET_DIR` installs from a directory diff --git a/docs/site/src/content/docs/operate/evidence-requirements.mdx b/docs/site/src/content/docs/operate/evidence-requirements.mdx index 3ddc0489a0..1be9c0c972 100644 --- a/docs/site/src/content/docs/operate/evidence-requirements.mdx +++ b/docs/site/src/content/docs/operate/evidence-requirements.mdx @@ -22,8 +22,7 @@ local tutorials, which start disposable versions of everything on your machine. - **HTTPS in front of it.** The process listens on a private address; your reverse proxy or ingress terminates TLS and routes traffic to it. - **A token issuer.** Evidence Gateway verifies bearer tokens from one OpenID Connect issuer and - issues none. Your identity provider fills this role, or - [Registry Mint](../../configure/mint/) does when you have none. + issues none. Configure a compatible OAuth issuer for this role. - **A signing key you control.** Production signing uses a Vault or OpenBao Transit key that never leaves the vault, reached through a proxy on the same host. A local development profile may use a key file instead. @@ -56,8 +55,7 @@ can use either one as a source through the same fixed HTTP request it makes to a ## How it ships Each release publishes reproducible binaries for the runtime, the `evidencectl` authoring tool, -Registry Mint, and the wallet delivery service, with an installer that verifies checksums before -installing. Each release also publishes container images built on a distroless base with no shell, +and the wallet delivery service, with an installer that verifies checksums before installing. Each release also publishes container images built on a distroless base with no shell, running as a non-root user, and records the digest of every image it promotes. Pin that digest rather than a movable tag. @@ -67,8 +65,7 @@ around the published image, and shows the shape: the reviewed bundle mounted unchanged, a separate runtime file and secret root, a persistent audit volume, and TLS in front. -{/* Evidence: crates/registry-evidencectl/install.sh; release/docker/Dockerfile.evidence; - release/docker/Dockerfile.mint; release/VERIFY.md; products/evidence/README.md. */} +{/* Evidence: crates/registry-evidencectl/install.sh; release/docker/Dockerfile.evidence; release/VERIFY.md; products/evidence/README.md. */} ## What stays your job diff --git a/docs/site/src/content/docs/operate/relay.mdx b/docs/site/src/content/docs/operate/relay.mdx index 73690c23d8..6f84d05a58 100644 --- a/docs/site/src/content/docs/operate/relay.mdx +++ b/docs/site/src/content/docs/operate/relay.mdx @@ -33,9 +33,8 @@ administrative trust boundary. Prepare a dedicated Unix service identity, a private listener behind Transport Layer Security (TLS) termination, a sealed package, the matching snapshot or live read-only source, an audit -location, independent audit-integrity and cursor-encryption keys, and a token issuer for protected -access profiles. [Configure Registry Mint](../../configure/mint/) when the deployment has no other -identity provider to serve as that issuer. +location, independent audit-integrity and cursor-encryption keys, and a compatible OAuth 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 diff --git a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx index ab931f3dd1..5007f6fd96 100644 --- a/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx +++ b/docs/site/src/content/docs/operate/retention-and-persistent-state.mdx @@ -1,12 +1,11 @@ --- title: Retention and persistent state -description: What Registry Relay, Evidence Gateway, Registry Mint, and Base Registry Engine retain, what expires, and what operators must preserve or ship off host. +description: What Registry Relay, Evidence Gateway, and Base Registry Engine retain, what expires, and what operators must preserve or ship off host. status: draft owner: registry-docs source_repos: - registry-stack - registry-evidence - - registry-mint last_reviewed: "2026-09-04" doc_type: reference locale: en @@ -15,8 +14,7 @@ standards_referenced: [] Use this page to decide which Registry Stack state needs backup, off-host shipping, expiry, or deletion policy outside the products. -It documents current Registry Relay, Evidence Gateway, Registry Mint, and Base Registry Engine -behavior. +It documents current Registry Relay, Evidence Gateway, and Base Registry Engine behavior. Retention enforcement and data-subject-rights workflows are outside the 1.0 product boundary, and so is record-level erasure everywhere except Base Registry Engine, which carries operator commands that erase retained history, change-request detail, and the oldest part of its audit journal. @@ -41,11 +39,10 @@ Two consequences follow from that boundary: | Store | What it can contain | Expiry or rotation | Operator control | |---|---|---|---| -| Relay audit sink | Chained attempt, terminal, and refusal envelopes binding the registry, resource, operation, access profile, disclosure profile, selected-field identifiers, contract revision, and source revision. It excludes tokens, selectors, raw principals, source values, response values, and raw identifiers. | Relay uses the same segmented, hash-chained JSONL sink Evidence Gateway and Registry Mint use. The active segment seals at a fixed `64 MiB` threshold and reopens under the next ascending sequence; Relay does not expose a configurable segment size or a retained-file count, and it never deletes a sealed segment itself. | Configure `audit.sink` and `audit.integrityKeyRef` in `runtime.yaml`. Archive sealed segments off host oldest sequence first; the shipping mechanics are identical to [Ship Evidence Gateway audit records off host](#ship-evidence-gateway-audit-records-off-host), since both sinks share the same segment-naming and hash-chain scheme. | +| Relay audit sink | Chained attempt, terminal, and refusal envelopes binding the registry, resource, operation, access profile, disclosure profile, selected-field identifiers, contract revision, and source revision. It excludes tokens, selectors, raw principals, source values, response values, and raw identifiers. | Relay uses the same segmented, hash-chained JSONL sink Evidence Gateway uses. The active segment seals at a fixed `64 MiB` threshold and reopens under the next ascending sequence; Relay does not expose a configurable segment size or a retained-file count, and it never deletes a sealed segment itself. | Configure `audit.sink` and `audit.integrityKeyRef` in `runtime.yaml`. Archive sealed segments off host oldest sequence first; the shipping mechanics are identical to [Ship Evidence Gateway audit records off host](#ship-evidence-gateway-audit-records-off-host), since both sinks share the same segment-naming and hash-chain scheme. | | Evidence Gateway audit chain | A keyed, hash-chained JSONL log of the access-attempt event before every source read and the disclosure-release event before every response. Audit carries reviewed identifiers and decision categories, never raw selector values, source values, credentials, tokens, or raw subject identifiers. | `auditStorage.maximumFileBytes` is a per-segment rotation threshold, not a total ceiling. When an append would exceed it, the runtime seals the active segment as `.` and opens a new active segment online, with no operator action. Nothing in the runtime deletes or compacts a sealed segment; total retention is entirely operator-owned. | Set `auditStorage.path`, `auditStorage.maximumFileBytes`, and the hash secret in `runtime.yaml`. Archive sealed segments to cold storage oldest sequence first, never touch the active segment while the service runs, and run `evidence verify-audit` on the cadence the deployment's audit policy requires. | -| Mint audit chain | A keyed, hash-chained JSONL log of token releases and denials. Records use keyed pseudonyms and omit raw assertions, access tokens, client ids, principals, authority values, actors, and subject values. | `audit.maximumFileBytes` is a per-segment threshold, not a total ceiling. Registry Mint seals full segments as `.` online and never deletes or compacts them. | Set `audit.path`, `audit.maximumFileBytes`, and the hash key in `mint.yaml`. Archive sealed segments oldest first, never touch the active segment while Registry Mint runs, and run `mint verify-audit` on the deployment's verification cadence. | | Base Registry Engine PostgreSQL database | The registry's own database. `registry_data` holds the current row of every record, `registry_internal` holds the retained revisions, the hash-chained audit journal and its head row, the webhook outbox, the change-request proposal and target snapshots, and the cached idempotency results, and `registry_source`, `registry_derived`, and `registry_context` hold views and functions the compiler generates. Records, their revisions, and proposal snapshots carry whatever the project's entities declare, personal data included. | Only a retained webhook payload expires on a schedule: `eventDelivery.payloadRetentionDays` in `runtime.yaml` defaults to `7` days and is capped at `30`, and the runtime clears a payload on the first successful delivery or once its expiry has passed. Revisions, audit records, and proposal snapshots are kept until an operator command removes them. | Back up the database; Base Registry Engine writes no local copy of it. `bregctl history erase`, `request-retention erase`, and `audit prune` are the only commands that remove retained bytes, and `audit export` is the only portable copy of the journal. [Retain, erase, and audit](../breg-retention/) states what each one destroys and what it leaves. | -| Operator-owned config, source, and secret paths | Relay's `runtime.yaml`, the sealed package at `packagePath`, and the bound SQLite source path; Evidence Gateway's governed bundle and runtime document; Registry Mint's governed public keys, signer configuration, and client registry; Base Registry Engine's signed package root, runtime file, project sources, module locks, generated files, test receipt, signature documents, and the `bregctl data` checkpoint, sidecar, and export output files on the operator host; secret references for all four. Source files and a `bregctl data export` output can contain personal data. | Registry Stack does not expire these files, except through the specific audit and cache mechanics listed for each store. | Mount source data read-only where possible; back up config, packages, and secrets through your platform controls. | +| Operator-owned config, source, and secret paths | Relay's `runtime.yaml`, the sealed package at `packagePath`, and the bound SQLite source path; Evidence Gateway's governed bundle and runtime document; Base Registry Engine's signed package root, runtime file, project sources, module locks, generated files, test receipt, signature documents, and the `bregctl data` checkpoint, sidecar, and export output files on the operator host; secret references for all three. Source files and a `bregctl data export` output can contain personal data. | Registry Stack does not expire these files, except through the specific audit and cache mechanics listed for each store. | Mount source data read-only where possible; back up config, packages, and secrets through your platform controls. | Relay V2 keeps no other durable, product-owned state beyond the audit sink and the operator-owned paths in [Durable state and externally retained records](#durable-state-and-externally-retained-records): @@ -69,8 +66,7 @@ SQLite file with no credential of its own to cache a token for. ## Audit retention -Relay writes chained JSONL audit records through the same segmented sink Evidence Gateway and -Registry Mint use, sealing full segments at a fixed `64 MiB` and never deleting a sealed segment +Relay writes chained JSONL audit records through the same segmented sink Evidence Gateway uses, sealing full segments at a fixed `64 MiB` and never deleting a sealed segment itself. The chain detects edits, insertions, reordering, and deletion after the first retained record @@ -79,8 +75,7 @@ records, still exist. Off-host durability is entirely the operator's responsibil no deployment-posture report, no `evidence_grade` startup mode, and no `doctor` command, so it never surfaces a finding about local-only retention risk the way Relay V1 did. There is also no shipper-acknowledgement cursor to check a shipper's progress against, and no `relay verify-audit` -or `relayctl` command that replays the chain: unlike Evidence Gateway's `evidence verify-audit` and -Registry Mint's `mint verify-audit`, Relay V2 has no offline chain-integrity command. Verify +or `relayctl` command that replays the chain: unlike Evidence Gateway's `evidence verify-audit`, Relay V2 has no offline chain-integrity command. Verify continuity by inspecting the sealed segment sequence directly, and treat a running deployment's `/ready` result as evidence the sink was writable at that moment, not as proof of complete off-host retention. @@ -89,7 +84,7 @@ Never truncate, rewrite, or reserialize audit files as part of cleanup. Back the old integrity keys for the audit retention period, and rotate `audit.integrityKeyRef` only as an intentional key-lifecycle event. -## Evidence Gateway and Registry Mint retention +## Evidence Gateway retention Evidence Gateway has no application database and persists no selector, source, evidence, or response data. Its durable state is its governed signer configuration and public keys plus the audit chain @@ -147,20 +142,6 @@ sealed segments and rise again as the chain grows. Audit bytes that grow without shipper that stopped, which is the condition worth alerting on. Neither gauge observes the receiver: they report what is still on local disk, never what external storage accepted. -Registry Mint's durable state is its governed signer configuration and public keys, the client -registration files under `clients.directory`, and the segmented audit chain. The external Transit -service retains the strict-mode private key. Onboarding, offboarding, and caller key -rotation reload the client directory on `SIGHUP` without a restart. The client-assertion replay -cache is in-memory only and clears on restart, so a restarted Registry Mint accepts a previously -used `jti` again for whatever lifetime remains on that assertion. Keep -`clientAssertion.maximumLifetimeSeconds` short enough that this window stays small. - -Run `mint verify-audit` to replay the Mint chain across every retained segment. The command reports -segment and record counts, the first and last sealed sequence, the chain tail, and whether the -active segment was verified. A running writer keeps the active segment out of the verification -pass and produces `active-segment: not verified`; stop Registry Mint for a complete retained-chain -pass. - ## Base Registry Engine retention A Base Registry Engine registry keeps its durable state in one PostgreSQL database rather than in diff --git a/docs/site/src/content/docs/reference/api-stability.mdx b/docs/site/src/content/docs/reference/api-stability.mdx index eaae9b1dd5..f5b01f011c 100644 --- a/docs/site/src/content/docs/reference/api-stability.mdx +++ b/docs/site/src/content/docs/reference/api-stability.mdx @@ -64,15 +64,9 @@ No shipping binary currently exposes a covered metric family. Relay serves no me selected-metrics mechanism defined in this page has nothing under it until a maintained product publishes a family through it. -Registry Mint and `evidence-oid4vci` service surfaces are deliberately absent from the table. Both -are supporting services rather than patterns of their own, and their configuration fields, route -shapes, and CLI flags may still change without a major release. Registry Mint's released image name -and digest-verification interface remain covered release artifacts. The current implementation -enforces the `client_credentials` grant with registered authentication, `private_key_jwt` (RFC 7523) -by default or an explicitly selected standard client-secret method, the collapse of every client -authentication failure to `invalid_client`, and authority written from the client registry rather -than from the caller's request. These are current properties, not compatibility promises. The -[Registry Mint reference](../mint/) defines the surfaces. +`evidence-oid4vci` is absent from the table because it is a supporting service rather than a +pattern of its own. Its configuration fields, route shapes, and CLI flags may still change without +a major release. ## What counts as a breaking change @@ -123,19 +117,14 @@ Every release documents a forward state path from its immediate predecessor. An upgrade that skips releases follows each sequential hop unless the target release explicitly certifies a direct path. -The reverse is not promised. Relay, Evidence Gateway, and Registry Mint all parse config with +The reverse is not promised. Relay and Evidence Gateway parse config with `deny_unknown_fields`, so a config file that uses keys introduced in a newer release fails to load on an older binary. Pin your config to the release you deploy. None of the three products has migrated state or a migration command. Relay owns no database: it reads the adopter's SQLite source through a read-only boundary and writes the keyed append-only audit stream named by `audit.sink` in its runtime file. Evidence Gateway persists the append-only -audit log named by `auditStorage` in its runtime file. Registry Mint persists its client registry -and the keyed append-only chain named by `audit.path`, while its client-assertion replay cache -remains in memory. A newer binary must be checked against these retained audit formats before -deployment. - -Relay's sealed package is an input to verify rather than state to migrate. `relay` reads the +audit log named by `auditStorage` in its runtime file. Relay's sealed package is an input to verify rather than state to migrate. `relay` reads the package format version out of `relay-package.json` and refuses to activate a package whose version string is not the one that binary was built against, so a package-format change is a recompile of the authoring project rather than a conversion of an installed package. @@ -186,16 +175,6 @@ operational or discovery routes and are unversioned by convention: `/health`, `/ `/openapi.json`, `/.well-known/evidence/jwks.json`, and `/.well-known/jwt-vc-issuer`. Evidence Gateway serves `/metrics` on a separate listener when one is configured, never on the request listener. -Registry Mint's routes are unversioned by protocol convention rather than by promise: the fixed, -non-configurable `POST /token`, the key set at the configured `signing.jwksPath` (default -`/.well-known/jwks.json`), `/.well-known/oauth-authorization-server`, -`/.well-known/openid-configuration`, `/health`, and `/ready`. -Discovery routes under `/.well-known/` follow RFC 8615 and their own upstream protocol -conventions, so they carry no version prefix in any product. - -The `evidence-oid4vci` supporting service has eight public routes. They are unversioned by OID4VCI -protocol convention and remain outside the compatibility promise this page describes. - ## Enforcement The promise is machine-checked where a checker exists: diff --git a/docs/site/src/content/docs/reference/apis/index.mdx b/docs/site/src/content/docs/reference/apis/index.mdx index a21d9583f1..afc1b1ec73 100644 --- a/docs/site/src/content/docs/reference/apis/index.mdx +++ b/docs/site/src/content/docs/reference/apis/index.mdx @@ -17,7 +17,7 @@ standards_referenced: import OpenApiSourcesTable from '../../../../components/OpenApiSourcesTable.astro'; Use this section to find each maintained HTTP surface. Evidence Gateway and its OID4VCI wallet -delivery front end have fixed generated contracts. Registry Mint has a hand-authored reference. +delivery front end have fixed generated contracts. Relay generates its description from each adopter's Registry contract. {/* Do not duplicate endpoint reference content in narrative pages. */} @@ -59,12 +59,6 @@ operations. It is generated from `src/data/openapi-sources.yaml`. -## Registry Mint: hand-authored supporting-service API - -Registry Mint has a fixed HTTP surface but no generated OpenAPI artifact. Its configuration, token -request and response, OAuth errors, discovery documents, key set, health, and readiness routes are -documented in the [Registry Mint reference](../mint/). - ## Relay: one document per deployment Relay has no product-level OpenAPI document to pin, so this site publishes none. diff --git a/docs/site/src/content/docs/reference/breg-api.mdx b/docs/site/src/content/docs/reference/breg-api.mdx index 605d1c9169..92831fd61c 100644 --- a/docs/site/src/content/docs/reference/breg-api.mdx +++ b/docs/site/src/content/docs/reference/breg-api.mdx @@ -57,7 +57,7 @@ therefore cannot distinguish a record that does not exist from one it may not se {/* Evidence: crates/registry-breg/src/auth.rs; crates/registry-breg/src/api/mod.rs, router(); crates/registry-breg/src/query.rs, access_profile; - crates/registry-breg/src/contract.rs, ProjectAccessProfileSource and AccessGrantSource. */} + crates/registry-breg/src/contract.rs, ProjectAccessProfileSource and AccessPermissionSource. */} ## Routes diff --git a/docs/site/src/content/docs/reference/environment-variables.mdx b/docs/site/src/content/docs/reference/environment-variables.mdx index a56f790fc9..a9c1695403 100644 --- a/docs/site/src/content/docs/reference/environment-variables.mdx +++ b/docs/site/src/content/docs/reference/environment-variables.mdx @@ -5,7 +5,6 @@ status: current owner: registry-docs source_repos: - registry-evidence - - registry-mint - registry-relay - registry-stack last_reviewed: "2026-08-11" @@ -18,11 +17,11 @@ This page lists Registry Stack's supported fixed environment-variable interfaces adopter tooling, and installers. It also records how Relayctl keeps fixture scratch state independent of host temporary-directory configuration. -The stack's other kind of environment variable is operator-named. Secret material such as an audit integrity key is not read from a fixed variable name: a configuration field carries a reference that names the variable, and the operator chooses the name. Relay and Base Registry Engine are the products that work that way, and the reference grammar is closed rather than free-form. Evidence Gateway and Registry Mint read no secret from the environment under either kind of name, as the sections for each product record. +The stack's other kind of environment variable is operator-named. Secret material such as an audit integrity key is not read from a fixed variable name: a configuration field carries a reference that names the variable, and the operator chooses the name. Relay and Base Registry Engine are the products that work that way, and the reference grammar is closed rather than free-form. Evidence Gateway reads no secret from the environment under either kind of name, as its section records. ## Configuration expansion -Relay, Evidence Gateway, and Registry Mint expand no environment reference inside their configuration documents. Each parses its documents as written: Relay in `RelayRuntime::parse_yaml` and `RegistryContract::parse_yaml` (`crates/registry-relay-v2/src/contract.rs`), Evidence Gateway in `RuntimeConfig::parse_yaml` (`crates/registry-evidence/src/config.rs`), and Registry Mint in `MintConfig::load` (`crates/registry-mint/src/config.rs`). Base Registry Engine is the exception: `breg` expands `${NAME}` expressions in its runtime file at load, as [its section](#base-registry-engine) records. +Relay and Evidence Gateway expand no environment reference inside their configuration documents. Each parses its documents as written: Relay in `RelayRuntime::parse_yaml` and `RegistryContract::parse_yaml` (`crates/registry-relay-v2/src/contract.rs`), and Evidence Gateway in `RuntimeConfig::parse_yaml` (`crates/registry-evidence/src/config.rs`). Base Registry Engine is the exception: `breg` expands `${NAME}` expressions in its runtime file at load, as [its section](#base-registry-engine) records. ## Relay @@ -158,23 +157,6 @@ toolset installer ships. It does not verify release authenticity. {/* Evidence: crates/registry-breg/install.sh; crates/registry-breg/src/main.rs; crates/registry-breg/src/runtime_config.rs. */} -## Registry Mint - -The `mint` binary reads these variables. - -| Name | Purpose | Default or required | -| --- | --- | --- | -| `MINT_CONFIG` | YAML config path for `mint check`, `mint serve`, and `mint verify-audit`. Equivalent to `--config`. | Required for those three subcommands, by flag or by variable. | -| `MINT_HEALTHCHECK_URL` | Exact numeric loopback or private-address HTTP URL for `mint healthcheck`. The path must be `/ready`; credentials, query, and fragment are refused. | Defaults to `http://127.0.0.1:8081/ready`. | -| `RUST_LOG` | Tracing filter for the JSON operational logs. Applies to every subcommand; `mint token` sends its logs to standard error so the access token stays alone on standard output. | Defaults to `info`. | - -Registry Mint's governed service public keys, Transit proxy settings, and client registrations are named -in its configuration: `signing.activePublicJwkFile`, `signing.publishedPublicJwkFiles`, -`signing.revokedKeyIds`, `signer`, and `clients.directory`. None is an environment variable. The -`mint token` subcommand is a caller tool rather than an operator one: it reads no server configuration -at all, and takes the caller's private JWK and any delegation subject as file paths rather than as -values. See the [Registry Mint reference](../mint/) for the full configuration surface. - ## Source The fixed environment variable names in this reference are transcribed from the CLI definitions, binary entry points, and install diff --git a/docs/site/src/content/docs/reference/errors.mdx b/docs/site/src/content/docs/reference/errors.mdx index 34636153ef..111ef623e8 100644 --- a/docs/site/src/content/docs/reference/errors.mdx +++ b/docs/site/src/content/docs/reference/errors.mdx @@ -1,12 +1,11 @@ --- title: Error and status code reference -description: The closed RFC 9457 problem set Relay returns, with each code's status, title, and detail, and where the Evidence Gateway and Registry Mint error contracts are documented. +description: The closed RFC 9457 problem set Relay returns, with each code's status, title, and detail, and where the Evidence Gateway error contract is documented. status: current owner: registry-docs source_repos: - registry-relay - registry-evidence - - registry-mint last_reviewed: "2026-08-11" doc_type: reference locale: en @@ -14,7 +13,7 @@ standards_referenced: [] --- Registry Stack HTTP surfaces use product-specific error contracts. This page carries Relay's set in -full and points to the Evidence Gateway, Registry Mint, and OID4VCI supporting-service contracts. +full and points to the Evidence Gateway and OID4VCI supporting-service contracts. ## Relay @@ -131,16 +130,6 @@ Every problem body has exactly `type`, `title`, `status`, `detail`, `code`, and never disclose subject data, source diagnostics, or the server-minted audit identifier. -## Registry Mint - -Registry Mint is an OAuth 2.0 token endpoint, so its token and protocol failures use the OAuth form -`{"error": ""}` over four codes (`invalid_request`, -`unsupported_grant_type`, `invalid_client`, and `server_error`), transcribed with their statuses -and causes in the [Registry Mint reference](../mint/). The one to know before reading it: -every client authentication failure has the same public status, code, body, and authentication -header, regardless of whether the asserted client id is registered. An unready `GET /ready` instead -returns `503 {"status":"not ready"}`. - ## Evidence OID4VCI supporting service The OID4VCI delivery service has its own closed protocol error vocabulary in the @@ -154,5 +143,4 @@ serialization and response headers are in `crates/registry-relay-v2/src/problem. is `TraceContext` in `crates/registry-platform-httpsec/src/server.rs`. The codes this page points at rather than repeating are transcribed on their own pages from -`crates/registry-evidence/src/problem.rs` with `products/evidence/contracts/problem-contract.yaml` -(Evidence Gateway) and `crates/registry-mint/src/error.rs` (Registry Mint). +`crates/registry-evidence/src/problem.rs` with `products/evidence/contracts/problem-contract.yaml` (Evidence Gateway). diff --git a/docs/site/src/content/docs/reference/evidencectl.mdx b/docs/site/src/content/docs/reference/evidencectl.mdx index 2d8c797d4b..4618546d39 100644 --- a/docs/site/src/content/docs/reference/evidencectl.mdx +++ b/docs/site/src/content/docs/reference/evidencectl.mdx @@ -142,7 +142,7 @@ requires a new Evidence Gateway generation. The local access commands do not define production authority or production clients. Production authority profiles remain in the deployment target's governed `governance.yaml`. Production client -registrations remain in a separately governed Registry Mint registry. Files under `access/` and +registrations remain in the deployment's governed OAuth issuer. Files under `access/` and `.evidence/` do not become production candidate inputs. Use `keygen`, `source suggest`, `request`, `verify`, and `audit` for their documented authoring or @@ -222,5 +222,4 @@ The generated `evidencectl` syntax is published only after its exact source vers recorded human review. - [Configure Evidence Gateway](../../configure/evidence/) -- [Registry Mint reference](../mint/) - [Build and deploy an Evidence Gateway project](../../tutorials/build-and-deploy-evidence-project/) diff --git a/docs/site/src/content/docs/reference/glossary.mdx b/docs/site/src/content/docs/reference/glossary.mdx index 706ecf8e55..b35941a66e 100644 --- a/docs/site/src/content/docs/reference/glossary.mdx +++ b/docs/site/src/content/docs/reference/glossary.mdx @@ -10,7 +10,6 @@ source_repos: - registry-platform - registry-manifest - registry-evidence - - registry-mint - solmara-lab last_reviewed: "2026-09-11" doc_type: reference @@ -52,7 +51,7 @@ Product names are always in English, including on future translated pages.
A named pairing of one access rule and one disclosure profile on a Registry Relay resource. The access rule is either `public` or a protected rule naming the required scope and, optionally, a purpose constraint and an authority row binding. A caller selects a profile with the `accessProfile` request parameter, and the profile it selects is both the authorization decision and the maximum disclosure the response may carry: no parameter, header, or token claim widens it. Statistical datasets carry one fixed access rule instead and do not accept the parameter. Declared in `registry.yaml` (`crates/registry-relay-v2/src/contract.rs`). Base Registry Engine uses the same two words and the same `accessProfile` request parameter for a different object: a named grant inside the registry project stating which scopes, purposes, and claims a token must carry and which operations, fields, and rows the caller may reach. See Control access per profile.
access token
-
The bearer credential a caller presents to Evidence Gateway. Evidence Gateway runs one authentication kind, `oidc-access-token`, whose issuer, audiences, accepted token types, algorithms, JWKS URI, and claim names are fixed in the immutable bundle, and it reads authority only from those configured claim names. Registry Mint issues such tokens for deployments with no identity provider. Any issuer may be used only when it satisfies that complete configured token profile and signs with a key accepted from the configured JWKS; matching claim names alone is insufficient.
+
The bearer credential a caller presents to Evidence Gateway. Evidence Gateway runs one authentication kind, `oidc-access-token`, whose issuer, audiences, accepted token types, algorithms, JWKS URI, and claim names are fixed in the immutable bundle, and it reads authority only from those configured claim names. Any issuer may be used only when it satisfies that complete configured token profile and signs with a key accepted from the configured JWKS; matching claim names alone is insufficient.
accountability record
The protected state Registry Casework keeps beside a hosted item's terminal outcome: the deciding person's issuer-qualified identity and the staff reason, held for the hosted kind's `accountabilityDays`. The Requester's terminal feed carries an opaque actor reference instead. A Supervisor who currently leads a team serving the item's queue resolves one terminal `eventId` through the separate accountability route, and that read is audited. See Retain, erase, and settle.
@@ -160,7 +159,7 @@ Product names are always in English, including on future translated pages.
The minimum-disclosure assertion service in this monorepo. Crate: `crates/registry-evidence`; product material: `products/evidence/`. Given authenticated authority, an authorized purpose, and a predefined requirement, Evidence Gateway serves a signed assertion that answers the requirement, not the source record, plus an SD-JWT VC serialization of that same stateless assertion under a frozen Version 1 profile. The SD-JWT VC format is never a credential lifecycle: no issuance session, holder-binding ceremony, status list, or revocation. Evidence Gateway is a separate greenfield product, not a reduced configuration of anything that came before it.
Evidence Gateway toolset
-
The four released binaries `evidence`, `evidencectl`, `mint`, and `evidence-oid4vci`. Releases that include the toolset publish reproducible binaries alongside a cosign-signed `SHA256SUMS` file. The installer stages and checksum-verifies every binary before replacement begins, attempts to restore the previous four-binary set if replacement fails, and does not itself authenticate `SHA256SUMS`. Use the tag-frozen release verification procedure for authenticity. `evidencectl` delegates runtime evaluation, signing, bundle validation, and fixture evaluation to `evidence`, and reuses the Evidence client and portable verifier for relying-party request preparation and offline response verification. It adds no Evidence Gateway semantics of its own. `mint`, built from the `registry-mint` crate, issues the access tokens `evidence` verifies. `evidence-oid4vci` is the optional wallet delivery front end and adds no assertion semantics.
+
The three released binaries `evidence`, `evidencectl`, and `evidence-oid4vci`. Releases that include the toolset publish reproducible binaries alongside a cosign-signed `SHA256SUMS` file. The installer stages and checksum-verifies every binary before replacement begins, attempts to restore the previous three-binary set if replacement fails, and does not itself authenticate `SHA256SUMS`. Use the tag-frozen release verification procedure for authenticity. `evidencectl` delegates runtime evaluation, signing, bundle validation, and fixture evaluation to `evidence`, and reuses the Evidence client and portable verifier for relying-party request preparation and offline response verification. It adds no Evidence Gateway semantics of its own. `evidence-oid4vci` is the optional wallet delivery front end and adds no assertion semantics.
evidence consumer
A service or process that uses returned evidence. Examples include a business-registration portal, procurement service, licensing authority, environmental reporting service, or credential verifier. The evidence consumer, caller, and decision owner can be the same component or separate components.
@@ -286,7 +285,7 @@ Product names are always in English, including on future translated pages.
Registry Stack runtime pattern for exposing existing registry source data through scoped, read-only HTTP routes with authentication, authorization, metadata, and audit. Registry Relay implements this pattern.
registry stack
-
The formal stack products: Registry Platform, Registry Manifest, Registry Relay, Base Registry Engine, Registry Casework, Evidence Gateway, and Registry Discovery, with Registry Mint as supporting token issuance. Use lowercase when referring to the concept.
+
The formal stack products: Registry Platform, Registry Manifest, Registry Relay, Base Registry Engine, Registry Casework, Evidence Gateway, and Registry Discovery. Use lowercase when referring to the concept.
purpose
The authorized reason a caller gives for one Evidence Gateway singular request or request batch. A purpose is a configured code drawn from the requirement's declared `purposes` list and from the caller's grant, never caller-defined prose. It is part of each complete authorization decision, is echoed into each assertion, and does not narrow disclosure. A declared purpose is an authorized selection from the granted set, not an identity-provider attestation.
@@ -330,9 +329,6 @@ Product names are always in English, including on future translated pages.
Registry Manifest
Portable Rust library and CLI for modeling, validating, and rendering standards-facing service, registry, form, and policy metadata without running Registry Relay. Provides `registry-manifest-core` and `registry-manifest-cli`.
-
Registry Mint
-
Small supporting service, not a fourth registry stack pattern, that issues short-lived, audience-bound access tokens to registered machine clients using the `client_credentials` grant, so a resource server such as Evidence Gateway or Registry Relay can require signed tokens without standing up a general-purpose identity provider. `private_key_jwt` is the client-authentication default; a standard authorization client may explicitly select `client_secret_basic` or `client_secret_post` compatibility. The client registry binds each client id to its authentication material and to the authority Registry Mint asserts for it. Registry Mint's tests drive Evidence Gateway's authenticator; the dependency runs one way only, and Evidence Gateway does not depend on Registry Mint. Crate: `crates/registry-mint`; binary: `mint`.
-
Registry Platform
Shared Rust crates for registry security and operational primitives, including auth helpers, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, crypto, SD-JWT VC helpers, and test fixtures. Their APIs are workspace-internal and not published compatibility contracts.
@@ -456,8 +452,8 @@ Product names are always in English, including on future translated pages. ## Style notes -- Formal product names (Registry Platform, Registry Relay, Registry Manifest, Registry Mint) and the adopter demo name (Solmara Lab) are always title case. The assertion product's name is Evidence Gateway, capitalized as a proper noun. -- Repo slugs and crate names (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-evidence`, `registry-mint`, `solmara-lab`) are always lowercase and monospace. A retired product's slug, such as `registry-notary` where a shipped schema or image name still spells it, follows the same rule. +- Formal product names (Registry Platform, Registry Relay, Registry Manifest) and the adopter demo name (Solmara Lab) are always title case. The assertion product's name is Evidence Gateway, capitalized as a proper noun. +- Repo slugs and crate names (`registry-platform`, `registry-relay`, `registry-manifest`, `registry-evidence`, `solmara-lab`) are always lowercase and monospace. A retired product's slug, such as `registry-notary` where a shipped schema or image name still spells it, follows the same rule. - Binary names (`relay`, `relayctl`, `evidence`, `evidencectl`, `mint`, `evidence-oid4vci`) are always lowercase and monospace, and are never used as the product name in prose. Write "Registry Relay refuses the request", not "relay refuses the request". - Legacy underscore forms (`registry_relay`) and old repo names (`decentralized-evidence-demo`) appear only in historical pages or `rename_status` fields. - The glossary provides a reference for standards acronyms but does not replace per-page first-use expansion. diff --git a/docs/site/src/content/docs/reference/index.mdx b/docs/site/src/content/docs/reference/index.mdx index 6eb1ff87bb..899d7777e0 100644 --- a/docs/site/src/content/docs/reference/index.mdx +++ b/docs/site/src/content/docs/reference/index.mdx @@ -24,7 +24,6 @@ from the task section that uses them. - [Evidencectl workflow reference](evidencectl/) - [Evidence Gateway configuration](evidence-configuration/) - [Evidence Gateway problems](evidence-problems/) -- [Registry Mint reference](mint/) - [Environment variables](environment-variables/) - [Errors and status codes](errors/) - [Validate a project](../verify/) diff --git a/docs/site/src/content/docs/reference/mint.mdx b/docs/site/src/content/docs/reference/mint.mdx deleted file mode 100644 index aff39573b3..0000000000 --- a/docs/site/src/content/docs/reference/mint.mdx +++ /dev/null @@ -1,445 +0,0 @@ ---- -title: Registry Mint reference -description: Configuration fields, authority profiles, token endpoint contract, and resource-server verification paths for Registry Mint. -status: current -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-09-08" -doc_type: reference -locale: en -standards_referenced: [] ---- - -Registry Mint is the `mint` binary built from the `registry-mint` crate. It issues short-lived -access tokens to registered machine clients, for deployments that have callers and no identity -provider. - -## Contract status - -Registry Stack as a whole is a pre-1.0 technical release. Registry Mint's configuration, HTTP, and -CLI surfaces do not appear among the covered surfaces in -[API stability and versioning](../api-stability/), so those surfaces may change without a major -release until Mint is added to that table. The released Mint image name and digest-verification -interface are covered release artifacts. - -The current implementation enforces the `client_credentials` grant, registered client -authentication with `private_key_jwt` (RFC 7523) as the default or an explicitly selected -client-secret compatibility profile, the collapse of every client-authentication failure to -`invalid_client`, and the rule that authority is written from the client registry and never from -the caller's own request. These are current properties, not compatibility promises. - -## Source of truth - -The `registry-mint` crate at `crates/registry-mint/` is the source of truth for Mint runtime, -configuration, HTTP, token, and audit behavior. Configuration parsing and validation live in -`crates/registry-mint/src/config.rs` and -`crates/registry-mint/src/clients.rs`, the HTTP surface in -`crates/registry-mint/src/server.rs`, token minting in `crates/registry-mint/src/token.rs`, and -client-secret generation in `crates/registry-mint/src/client_secret.rs`, and the keyed audit -boundary in `crates/registry-mint/src/audit.rs`. The public error shape lives in -`crates/registry-mint/src/error.rs`. Release image and digest claims are owned under `release/`; -Evidence and Relay verification claims are owned by those runtimes and their shared OIDC code. -`crates/registry-mint/README.md` covers the Mint surface in prose. - -## Official container image - -Starting with `v0.21.0`, Registry Stack publishes Registry Mint as -`ghcr.io/registrystack/mint:v0.26.1`. The image uses the distroless nonroot -runtime identity, UID and GID 65532, with no shell or package tools. Pin the digest recorded in the -checksum-covered `registry-stack-v0.26.1-release-manifest.json` public release asset, following -[`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.26.1/release/VERIFY.md). - -The image reads `MINT_CONFIG`, which defaults to -`/etc/registry-mint/config.yaml`. Mount the configuration and immutable relative inputs read-only -under `/etc/registry-mint`. Set `audit.path` to an absolute path under the writable -`/var/lib/registry-mint/audit` directory and attach persistent storage -owned by UID and GID 65532. The image exposes port `8081` and serves -`GET /health` for an external HTTP probe; it does not include a Docker -`HEALTHCHECK`. - -## Configuration reference - -One YAML document, loaded by `MintConfig::load`. Fields use `camelCase` keys, reject unknown -fields, and every relative path resolves against the configuration file's own directory. The -following tables group the surface by the struct that owns each field. - -### Top level - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `version` | integer | required | Must equal `1`. | -| `validationMode` | `strict` or `supervised-local-development` | `strict` | Strict mode requires Transit. Supervised local development also permits `local-jwk`. | -| `issuer` | string (URL) | required | Strict mode requires HTTPS with a host and no credentials, query, or fragment. Supervised local mode also accepts only canonical `http://127.0.0.1:`, aligned with the listener, JWKS path, and `${issuer}/token` assertion audience. | -| `listener` | object | required | See [Listener](#listener). | -| `signing` | object | required | See [Signing](#signing). | -| `signer` | object | required | The local-JWK or Transit signing provider. | -| `secretProviders` | object | required | File-secret root for local signing and audit masters. | -| `audit` | object | required | See [Audit](#audit). | -| `accessTokens` | object | required | See [Access tokens](#access-tokens). | -| `clientAssertion` | object | required | See [Client assertion](#client-assertion). | -| `clients` | object | required | See [Clients](#clients). | - -### Listener - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `address` | string (IP address) | required | Parsed as an `IpAddr`. | -| `port` | integer (`u16`) | required | | -| `maximumRequestBytes` | integer (`u32`) | `16384` | Bounded `1024..=1048576`. | -| `requestTimeoutMilliseconds` | integer (`u64`) | `5000` | Bounded `1..=30000`. | - -### Signing - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `algorithm` | `ES256` | required | Fixed service-token signing algorithm. Client assertions have their own allowlist. | -| `activePublicJwkFile` | path | required | Exact six-member public ES256 P-256 JWK for the signer. Its `kid` must be the 43-character RFC 7638 thumbprint, and the file name must be `.jwk.json`. | -| `publishedPublicJwkFiles` | list of paths | `[]` | Other current public P-256 JWKs that remain in JWKS during a planned rotation. Active plus published is limited to 33 unique keys. | -| `revokedKeyIds` | list of thumbprints | `[]` | At most 33 unique RFC 7638 thumbprints. They cannot be active, published, or returned by JWKS. | -| `jwksPath` | string | `/.well-known/jwks.json` | A plain absolute path: one or more non-empty segments of `A-Z a-z 0-9 - . _ ~`, no dot segments, and no query, fragment, or route pattern. Must not be `/token`, `/health`, `/ready`, `/.well-known/oauth-authorization-server`, or `/.well-known/openid-configuration`. | - -### Signer - -`signer.kind` is `transit` in strict mode and `local-jwk` only for supervised local development. -A Transit configuration has an absolute `unixSocketPath`; non-empty `mount` and `keyName` values of -at most 128 ASCII letters, digits, `-`, or `_`; a nonzero pinned `keyVersion`; and -`timeoutMilliseconds` from 1 through 30000. The socket points to a workload-local proxy, not a -network provider. Mint verifies the public key, Transit custody controls, key version, and a -signature before readiness admits traffic. A local-JWK configuration has only `privateKeyRef`; its -resolved key must match `activePublicJwkFile`. - -### Secret provider - -`secretProviders.file.root` must be absolute. Every secret reference uses the exact -`secret:file/` grammar. The name starts with a lowercase ASCII letter, continues with lowercase -letters, digits, `.`, `_`, or `-`, and is at most 128 characters. - -### Audit - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `path` | path | required | One keyed JSONL chain. The parent directory and existing chain must be owner-only. | -| `maximumFileBytes` | integer (`u64`) | required | Per-segment rotation threshold, from `1048576` through `1099511627776` bytes. | -| `hashKeyRef` | `secret:file/` | required | At least 32 bytes in a regular, owner-only file beneath `secretProviders.file.root`. Must differ from the local signing key reference. | -| `hashKeyVersion` | integer (`u32`) | required | Must be non-zero. Labels keyed pseudonyms so an operator can identify the correlating key generation. | - -Mint takes a single-writer lock, verifies the active chain at startup, and synchronizes the chain -and its parent directory for each append. When an append would exceed `maximumFileBytes`, Mint -seals the active segment as `.` and opens a new active segment online. -The keyed chain continues across segments. Mint does not delete or compact sealed segments, so the -operator owns total capacity, backup, and retention. Verify retained records with: - -```sh -mint verify-audit --config /etc/registry-mint/config.yaml -``` - -The command verifies every sealed segment and reports a missing sequence distinctly. It verifies -the active segment when no Mint process owns the writer lock; otherwise it reports -`active-segment: not verified`. Archive sealed segments oldest first. Do not rename or archive the -active segment while Mint is running. - -The chain stores a random operation id for every decision. Successful release records add the -token `jti`, signing key id, expiry, delegated status, and keyed pseudonyms for correlation. -Denial records carry only a value-free error category. Raw assertions, access tokens, client ids, -principals, authority values, actors, and subject values are not stored. - -### Access tokens - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `audiences` | list of strings (1..=16 entries, 1..=512 bytes each) | required | Written as the minted token's `aud`. | -| `lifetimeSeconds` | integer (`u64`) | required | Bounded `60..=3600`; a deployment with any standard authorization registration is further bounded to `<=900`. | -| `claims` | object | none | Evidence claim names. Required when any client uses the Evidence authority profile. Scoped-only deployments omit it. | - -### Access token claim names - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `principal` | string | `sub` | | -| `requesterTags` | string | required | | -| `evidenceAudience` | string | required | | -| `grantId` | string | required | | -| `grantAuthority` | string | required | | -| `actor` | string, optional | none | Required only to issue delegated tokens. | - -These names must match Evidence Gateway's own claim-name configuration exactly. Claim names -must be distinct, must not shadow the registered JWT claims Registry Mint writes itself -(`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`, `client_id`, `scope`), and `sub` may only be used for -`principal`. - -### Client assertion - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `audience` | string (URL with host) | required | The value client assertions must carry as `aud`. Strict mode requires HTTPS. Canonical supervised local HTTP requires exactly `${issuer}/token`. | -| `maximumLifetimeSeconds` | integer (`u64`) | `300` | Bounded `30..=600`. | -| `algorithms` | list of enum: `EdDSA`, `ES256`, `RS256` | required, non-empty | Accepted client assertion signature algorithms. | -| `replayCacheEntries` | integer (`usize`) | `8192` | Minimum `256`. | - -### Clients - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `directory` | path | required | Directory of at most 4096 per-client `*.yaml` registration files. The one reloadable part of the configuration: `SIGHUP` loads a complete replacement in place; a failed reload retains the prior registry. | - -### Client registration fields (`clients/*.yaml`) - -One file per client, parsed by `crates/registry-mint/src/clients.rs`. - -| Field | Type | Default | Notes | -| --- | --- | --- | --- | -| `clientId` | string (1..=256 bytes) | required | Unique across the loaded registry. | -| `principal` | string (1..=512 bytes) | required | Must not be blank. | -| `evidenceAudience` | URL string (at most 512 bytes) | Evidence profile only | Required with `requesterTags`; cannot coexist with `authorization`. | -| `requesterTags` | 1..=32 unique strings, each 1..=256 bytes | Evidence profile only | Required with `evidenceAudience`; cannot coexist with `authorization`. | -| `grant` | object: `id`, `authority` | Evidence profile only, optional | Both values are 1..=512 bytes when present. Cannot coexist with `authorization`. | -| `delegation` | object: `actors`, `subjectClaims` | Evidence profile only, optional | `actors`, when present, has 1..=64 unique values of 1..=512 bytes. `subjectClaims` has 1..=16 non-overlapping claim paths. Cannot coexist with `authorization`. | -| `authorization` | object: `scopes`, `claims` | Standard profile only | `scopes` has 1..=64 unique RFC 6749 scope-tokens. `claims` has at most 32 entries, each one direct string or a list of 1..=64 unique direct strings. Cannot coexist with Evidence fields. | -| `clientAuthentication` | object | `method: private-key-jwt` | Selects `private-key-jwt` or `client-secret`. The secret method is valid only with standard `authorization` and carries one or two canonical `sha256:` fingerprints under `secretFingerprints`. | -| `keys` | list of public JWKs (1..=8 entries) | required for `private-key-jwt` | Key ids are non-empty, at most 256 bytes, and unique per client. A document carrying a private key member is rejected. Must be omitted for `client-secret`. | - -Every registration selects exactly one profile. Evidence authority consists of -`evidenceAudience` plus `requesterTags`, with optional `grant` and `delegation`. Standard -authorization consists of `authorization.scopes` plus optional `authorization.claims`. A -registration that selects neither profile or combines both profiles fails the whole registry -load. Omitting `clientAuthentication` preserves `private-key-jwt`; the client-secret method must -be selected explicitly and cannot be combined with client keys, Evidence authority, or -delegation. Each registration must be a regular file no larger than 256 KiB. Loading is all-or-nothing: -one malformed or duplicated registration refuses the complete startup or reload. - -Each standard scope is 1 to 256 bytes and follows the RFC 6749 scope-token grammar. Direct claim -names are 1 to 128 bytes. A direct claim value is either one string or a list of 1 to 64 unique -strings, and each string is non-empty and at most 512 bytes. A resource server boundary that -matches a row against a set of permitted values reads an array-valued claim and refuses a scalar, -so a registration that must satisfy one uses the list form. The -names `iss`, `aud`, `exp`, `iat`, `nbf`, `jti`, `client_id`, `sub`, and `scope` are reserved and -cannot be configured as direct claims. When `accessTokens.claims` is present, direct standard -claims also cannot reuse any configured Evidence claim name. -Mint projects the complete maximum-width signed response for each standard registration and -refuses startup or reload when that response could exceed the shared client response ceiling of -16 KiB. - -## Token endpoint contract - -### Request - -Each registration accepts exactly one authentication method. A `private-key-jwt` registration -uses this form: - -```text -POST /token -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials -&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer -&client_assertion= -``` - -The path `/token` is fixed and not configurable. `grant_type` must equal `client_credentials` -exactly, and `client_assertion_type` must equal -`urn:ietf:params:oauth:client-assertion-type:jwt-bearer` exactly. `client_assertion` is a -compact JWS whose payload carries `iss` and `sub` equal to the client id, `aud` equal to the -configured `clientAssertion.audience`, a `jti`, and `iat`/`exp` inside -`clientAssertion.maximumLifetimeSeconds`. - -The protected header uses one configured algorithm, a `typ` of `JWT` or -`client-assertion+jwt`, and a `kid` present in that client's registered public keys. The compact -assertion is at most 16 KiB; its decoded header and claims are each at most 8 KiB; and the client id -and `jti` are each at most 256 bytes. A delegated request adds the signed -`on_behalf_of: {actor, subject}` claim. - -A valid assertion's `(client_id, jti)` is accepted at most once while the assertion remains -acceptable, including clock skew. Client-registry reloads do not clear spent identifiers. A -delegation refused before authentication completes does not spend the identifier. - -An explicitly selected client-secret registration accepts HTTP Basic authentication -(`client_secret_basic`): - -```text -POST /token -Authorization: Basic -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials -``` - -Form-encode the client id and client secret separately before joining them with the colon delimiter -and Base64-encoding the result. Encoding the combined string is not equivalent when either component -contains a reserved character such as `:`. - -Alternatively, use form-body authentication (`client_secret_post`): - -```text -POST /token -Content-Type: application/x-www-form-urlencoded - -grant_type=client_credentials -&client_id= -&client_secret= -``` - -A request that mixes authentication methods is refused. A caller may send the optional OAuth -`scope` form member for client compatibility, but Registry Mint ignores it and writes only the -registration's server-governed scopes and direct claims. Registry Mint returns no refresh token; -a client obtains a new short-lived token by repeating Client Credentials authentication. - -### Response - -`200 OK`, `Content-Type: application/json`: - -```json -{ - "access_token": "SYNTHETIC_FIXTURE_TOKEN", - "token_type": "Bearer", - "expires_in": 300 -} -``` - -`access_token` is a compact JWS with header `{"alg": "ES256", "typ": "at+jwt", -"kid": ""}`. Its claims carry the standard `iss`, `aud`, `iat`, `nbf`, -`exp`, `jti`, `client_id`, and `sub` (the principal). An Evidence-profile token adds the -authority claims named under `accessTokens.claims` and omits the response `scope` member. A -standard-profile token adds its space-delimited `scope` claim and its direct registered claims, -each written as a JSON string or, where the registration lists values, a JSON array of strings; -its response adds the same `scope` string. `expires_in` equals -`accessTokens.lifetimeSeconds`. Mint durably appends the matching token-release audit record -before sending this response. If that append fails, the token is not released. - -### Errors - -Token and protocol error responses use `{"error": ""}`, from -`crates/registry-mint/src/error.rs`. Readiness has the separate response described in [Other endpoints](#other-endpoints). - -| Code | Status | When | -| --- | --- | --- | -| `invalid_request` | `400` | The content type is wrong; the bounded request body is unreadable, oversized, or timed out; a recognized form field is missing or duplicated; authentication methods are mixed; or `client_assertion_type` is not the exact `jwt-bearer` URN. Unknown form members are ignored. A missing `grant_type` lands here, not under `unsupported_grant_type`. | -| `unsupported_grant_type` | `400` | `grant_type` is present but is not `client_credentials`. | -| `invalid_client` | `401` | Every client authentication failure: unknown client id, wrong authentication method, bad signature or secret, replayed `jti`, or expired assertion. Registry Mint collapses these into one code so the endpoint cannot be used to probe which client ids are registered. A failed HTTP Basic attempt carries `WWW-Authenticate: Basic realm="registry-mint"`. | -| `server_error` | `500` | An internal failure, including failure to durably audit the token decision. | - -### Other endpoints - -| Path | Purpose | -| --- | --- | -| `GET ` (default `/.well-known/jwks.json`) | Public keys for verifying minted tokens, `application/jwk-set+json`. | -| `GET /.well-known/oauth-authorization-server` | Metadata pointing at the token endpoint and key set and advertising `private_key_jwt`, `client_secret_basic`, and `client_secret_post`. | -| `GET /.well-known/openid-configuration` | The same metadata at the OIDC discovery path used by Registry Relay and other OIDC-compatible verifiers. | -| `GET /health` | Liveness. | -| `GET /ready` | Readiness. Returns `503` while no client is registered, after an audit write failure poisons the writer, or while the signing provider is unavailable. Provider readiness recovers after a successful self-test. | - -## How a client, Registry Mint, and Evidence Gateway interact - -This sequence shows the preferred private-key client path, from a client that holds only its own -private key to a signed assertion. Every -label in this diagram is stated in the prose and tables on this page; the diagram is a summary of -those contracts, not a second source for them. - -```mermaid -sequenceDiagram - autonumber - participant C as Registered client - participant M as Registry Mint - participant E as Evidence Gateway - participant S as Source - - Note over C,E: Set up once, before any request: the client's public JWK
sits in Mint's client registry, and Evidence Gateway's
authentication block names Mint's issuer and key set. - - C->>C: Sign an assertion with
the client's own private key - C->>M: POST /token
client_credentials + assertion - M->>M: Verify against that client's
registered keys, refuse a used jti - M->>M: Durably append the keyed
token-release audit record - M-->>C: Access token, authority written
from the registry - - C->>E: POST /v1/evidence
Bearer access token - E->>M: GET the published key set - M-->>E: Public keys, held
between requests - E->>E: Verify the token, read authority
by the configured claim names - E->>S: Read what the
definition needs - S-->>E: Source response - E-->>C: Signed assertion -``` - -Everything through the access-token return is the whole of Registry Mint's job. It never sees the -source, the acceptance definition, or the Evidence assertion. Evidence Gateway has no Mint runtime -dependency. It treats Mint as an ordinary issuer configured with exact issuer, audiences, token -types, algorithms, JWKS URI, authority claim names, maximum token lifetime, and denied key ids. Any -issuer can be used only when it satisfies that complete token profile. - -The exact wire shapes the diagram abbreviates are given in full: -[Token endpoint contract](#token-endpoint-contract) for the assertion and token, -[Other endpoints](#other-endpoints) for the key set path, and -[How Evidence Gateway verifies these tokens](#how-evidence-gateway-verifies-these-tokens) for the claim names. - -Two failures collapse deliberately and are worth reading beside the diagram. A client -authentication failure has the same public `401 invalid_client` response whether the asserted -client id is unknown or its assertion fails another check. A key-set retrieval failure prevents -Evidence Gateway from verifying a token: with no key set ever retrieved, every request is rejected -until one can be, and with a key set already held, that held set keeps being accepted only until its -allowance runs out. Caller-facing authentication errors remain generic; readiness and bounded -operational records expose the dependency failure to operators. - -## How Evidence Gateway verifies these tokens - -Evidence Gateway's own `authentication` configuration block, defined in -`crates/registry-evidence/src/config.rs` (`AuthenticationConfig`), names the same claims Registry -Mint writes: `principalClaim`, `requesterTagsClaim`, `evidenceAudienceClaim`, `grantIdClaim`, -`grantAuthorityClaim`, and an optional `actorClaim`, alongside `issuer`, `audiences`, -`tokenTypes`, `algorithms`, and `jwksUri`. Evidence Gateway verifies a presented token against that -configuration in `crates/registry-evidence/src/auth.rs` (`Authenticator`), reading each claim by -the name configured there rather than any hardcoded name. Setting `authentication.issuer` and -`authentication.jwksUri` to Registry Mint's own `issuer` and published key set, and setting each -claim name to match `accessTokens.claims` on Registry Mint, and setting its required maximum token -lifetime and `revokedKeyIds`, is what lets one token flow between the two. Evidence Gateway checks -the key denylist before it selects a cached JWKS entry. - -This is proven by tests, not only by matching configuration. `registry-mint`'s -`tests/evidence_compatibility.rs` drives the real Registry Mint router over a real on-disk -deployment and feeds the minted token to Evidence Gateway's own authenticator; -`tests/delegated_subject_binding.rs` does the same for delegated, subject-bound tokens, running -Evidence Gateway's own entitlement match and selector resolution over a token minted by the real Registry -Mint router. The dependency runs one way only: Registry Mint's tests exercise Evidence Gateway's -authenticator, and Evidence Gateway does not depend on Registry Mint. - -## How Registry Relay verifies these tokens - -Registry Relay configures one issuer and one exact audience per deployment. Its ordinary OAuth -JWT verifier validates Mint's issuer, audience, `at+jwt` token type, ES256 signature, key, -lifetime, principal, token identifier, and space-delimited `scope` claim. The selected operation -or access profile then requires one exact compiled scope. Optional purpose and row-authority -checks read direct verified string claims. - -Pairing therefore requires these exact configuration matches: - -- `accessTokens.audiences` is a one-entry list equal to the Relay runtime's issuer audience. -- Relay's `discoveryUrl` points to Mint's discovery document and its algorithm allowlist contains - `ES256`. -- `authorization.scopes` includes every exact compiled operation or access-profile scope required - by that client. Keeping the registration to the minimum needed set is governance guidance; Relay - checks required-scope membership rather than rejecting unrelated extra scopes. -- `authorization.claims` supplies the configured purpose or row-authority values that the - selected Relay access rule requires. -- `accessTokens.lifetimeSeconds` is between 60 and 900 seconds, within Relay's fixed 15-minute - token-lifetime ceiling. - -Registry Mint fixes these values in the server-side registration. A token request's optional -`scope` member is ignored, no authority form member is accepted, and Mint does not copy authority -from the client assertion. -Registry Relay has no Mint-specific verifier branch and keeps working with any authorization -server that emits the same token profile. - -The compatibility test in `crates/registry-relay-v2/tests/acceptance_http.rs` starts the real -Mint router and the real Relay router. The Registry Relay client obtains a token through its -shared private-key-JWT provider and uses the registered scope, purpose, and row authority to -complete a protected, disclosure-limited lookup. - -`crates/registry-mint/tests/client_secret_compatibility.rs` separately drives both standard -client-secret methods through Registry Mint's real router, verifies the released token with the -shared OIDC verifier, and covers token reacquisition, rotation, revocation, authority -containment, and audit redaction. - -## Next - -- [Configure Registry Mint](../../configure/mint/) -- [Use Registry Mint with QGIS and standard OAuth clients](../../configure/use-mint-with-qgis-and-standard-oauth-clients/) -- [API stability and versioning](../api-stability/) diff --git a/docs/site/src/content/docs/security/hardening-checklist.mdx b/docs/site/src/content/docs/security/hardening-checklist.mdx index 5e9062e088..197f500c26 100644 --- a/docs/site/src/content/docs/security/hardening-checklist.mdx +++ b/docs/site/src/content/docs/security/hardening-checklist.mdx @@ -6,7 +6,6 @@ owner: registry-docs source_repos: - registry-relay - registry-evidence - - registry-mint - registry-platform last_reviewed: "2026-08-22" doc_type: how-to @@ -47,8 +46,6 @@ Relay package. It assumes you have already configured your services per - Have `evidence check` runnable against the exact revision you intend to serve. Evidence Gateway has no posture endpoint, no profile declaration, and no admin surface: its equivalent of a gate finding is a startup or readiness failure, so the offline commands are where you find problems first. -- When the deployment runs Registry Mint, have `mint check` and `mint verify-audit` runnable - against the exact configuration and retained chain you intend to serve. ## Keys and custody @@ -73,7 +70,7 @@ Relay package. It assumes you have already configured your services per production and evidence-grade deployments. Require P-256, signing enabled, non-exportable key material, plaintext backup disabled, and an exact governed public-key match. Local JWK signing is for local assurance only. See - [Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/). + [Configure Transit signing for Evidence Gateway](../../tutorials/move-evidence-to-production-signing/). - Relay: supply every secret through one of the two reference grammars its runtime file accepts, `secret:env/` or `secret:file/`, and never as a literal value. `` is uppercase ASCII, digits, and underscores starting with a letter; `` is a single flat lowercase @@ -101,18 +98,6 @@ Relay package. It assumes you have already configured your services per - Relay: it holds no signing key of its own, because it signs nothing. If your review process expects a signature over a Relay response or a Relay package, that expectation is not met by this product. -- Registry Mint, when the deployment runs it: use the same active, published, revoked P-256 service - key posture and Transit boundary as Evidence Gateway. Treat `clients.directory` as key material. Any - file written there registers a client and the authority Registry Mint will assert for it, and a - running process re-reads the directory on `SIGHUP` without a restart. -- Registry Mint: keep `private-key-jwt` as the client-authentication default. When a managed - standard client cannot sign assertions, generate a unique installation secret with - `mint client-secret generate`, deliver its owner-only file outside configuration, and register - only the printed fingerprint. Never share one secret across people or installations. Allow no - more than the two-fingerprint overlap needed for a planned rotation. -- Registry Mint: generate its audit HMAC key independently from its signing key, give it at least - 32 bytes of random material, and keep it in a separate owner-only, non-symlink file. Retain the - key for as long as its audit chain must remain verifiable. ## Freeze the Evidence Gateway deployment project @@ -204,14 +189,6 @@ Relay package. It assumes you have already configured your services per - Evidence Gateway: run `evidence verify-audit` over the whole audit directory during backup, restore, and incident procedures, and on whatever cadence your retention policy requires. Startup verifies only the active segment, so this pass is what proves sealed history was not tampered with. -- Registry Mint: put `audit.path` on durable storage whose permissions, capacity, backup, restore, - and retention you own. Mint synchronizes the token-release record before returning a token and - fails closed when that write fails. Treat `audit.maximumFileBytes` as a per-segment threshold, - not a total ceiling. Rotation happens online, but Mint never deletes or compacts sealed segments. -- Registry Mint: run one process per `audit.path`. Mint verifies the retained chain and takes an - exclusive lock at startup. Archive sealed segments oldest first, never rename or archive the - active segment while Mint runs, and run `mint verify-audit` during backup, restore, incident - procedures, and on the cadence your retention policy requires. - Relay: `audit.sink` is a path on storage whose append durability, permissions, capacity, backup, restore, and retention you own. It is a plain path, not a choice between transports, so there is no `stdout` mode to fall back to and nothing to configure away. @@ -280,13 +257,6 @@ Relay package. It assumes you have already configured your services per fixed-origin verification on. Evidence Gateway ignores `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` and has no application-level proxy, so an ambient proxy variable will not silently redirect a source call. -- Registry Mint, when the deployment runs it: put TLS in front of it. Registry Mint serves plain - HTTP and expects termination it does not manage, while Evidence Gateway requires the token issuer and - its key set to be HTTPS with no loopback exception. Keep `accessTokens.lifetimeSeconds` short - inside its `60..=3600` bound, because a minted token is accepted until it expires and no service - in this stack revokes one. Standard authorization is capped at 900 seconds. Apply bounded - request-body, connection, and rate controls at the trusted ingress for `/token`, preserving - Registry Mint's generic `invalid_client` response instead of exposing credential diagnostics. ## Declared posture, and why neither product has one @@ -342,10 +312,6 @@ serve, and a startup that fails rather than degrades. Evidence Gateway defines no credential-status or lifecycle feature, so plan incident response around short assertion validity windows and service-key denylisting rather than recall. - Rotate a compromised source credential at its own source and restart the affected process. - For a compromised Registry Mint client key, remove its public JWK; for a compromised client - secret, remove its fingerprint when another valid fingerprint remains, or remove the complete - registration when it is the last or only secret. Send `SIGHUP` to reload the client registry - immediately. Already issued bearer tokens remain valid until expiry. - Withdraw a Relay disclosure by publishing a new package and restarting. Relay has no admin endpoint, no reload signal, and no way to narrow a compiled operation at runtime, so the only levers during an incident are stopping the process and deploying a corrected package. Plan for @@ -353,8 +319,7 @@ serve, and a startup that fails rather than degrades. - Preserve the audit trail for post-incident review. For Evidence Gateway, take the whole audit directory including sealed segments and run `evidence verify-audit` over it before and after moving it; for Relay, hash-chained, fail-closed audit records are the record a deployment reconstructs a - request timeline from (REQ-SEC-G-008, REQ-SEC-G-009). For Registry Mint, preserve the chain and - matching HMAC key, then run `mint verify-audit` before and after moving it. + request timeline from (REQ-SEC-G-008, REQ-SEC-G-009). - Expect a `403` from Evidence Gateway to tell you nothing. Every authorization refusal collapses to one generic `evidence.denied` problem, deliberately, so it is not an oracle for which check failed. Debug it from trusted local state in this order: the bearer principal is in the deployed @@ -383,9 +348,6 @@ serve, and a startup that fails rather than degrades. provider, the pinned audit sink, and every source credential, including a bounded OAuth token bootstrap where a source uses client credentials. Readiness sends no evidence-data request and probes no source data endpoint, so a passing probe is not proof that a source returns data. -- Registry Mint: confirm `GET /ready` succeeds with at least one registered client, issue one - synthetic token, and run `mint verify-audit` to confirm its token-release record joined the - retained chain. A passing readiness probe does not prove off-host receipt or retention. - Send a request that should be audited and confirm a corresponding record lands in your configured sink, not only `stdout`. - Evidence Gateway: request an assertion and verify the stored response offline with @@ -407,8 +369,6 @@ serve, and a startup that fails rather than degrades. | Evidence Gateway refuses to start on a non-immutable input | The bundle directory, `runtime.yaml`, a captured artifact, or a named CA bundle file is writable by the service process, or the secret root is reachable by group or other | Re-freeze the project (`chmod -R a-w bundle && chmod 444 runtime.yaml`), tighten the secret root to `0700`, and restart | | Evidence Gateway refuses to start on an invalid bound source extract | The source extract the runtime file names is writable; a distinct refusal from the non-immutable-input row above | Make the extract file non-writable and restart | | A second Evidence Gateway process fails at startup with a sink-locked error | Two processes point at the same `auditStorage.path` | Run one writer per audit path; use active/passive with restart-on-failure rather than a second replica | -| A second Registry Mint process fails to start, or `mint verify-audit` rejects its chain | Another writer holds `audit.path`, or retained records were changed, reordered, or written with another key | Run one writer per path and restore the matching retained chain and HMAC key; do not delete audit records to make startup pass | -| Registry Mint returns `500 server_error` and then fails readiness | The process could not durably append a token decision and poisoned its audit writer | Stop traffic, preserve and verify the chain, restore writable durable storage, and restart Mint; the failed request did not receive a token | | Evidence Gateway returns `403 evidence.denied` for a request you expected to work | Any one of the audience, grant, authority, or response-format checks failed; the response never says which | Work through the ordered checks in [Incident response](#incident-response) against local configuration, then read the refusal phase from the audit chain | ## Next diff --git a/docs/site/src/content/docs/security/index.mdx b/docs/site/src/content/docs/security/index.mdx index 1cddb63797..38b348ddd4 100644 --- a/docs/site/src/content/docs/security/index.mdx +++ b/docs/site/src/content/docs/security/index.mdx @@ -8,7 +8,6 @@ source_repos: - registry-relay - registry-platform - registry-evidence - - registry-mint last_reviewed: "2026-08-22" doc_type: explanation locale: en @@ -137,16 +136,7 @@ is replay prevention; and the mode's profile is frozen, a status it carries only negative test it names resolves to an executable test. [Known limitations](../explanation/known-limitations/) states both modes side by side. -Where a deployment has no identity provider, Registry Mint issues the access tokens Evidence Gateway -or Registry Relay verifies. Mint authenticates each registered client by the one method its -registration selects. Asymmetric client assertions remain the default and the only method for -Evidence or delegated authority; a standard authorization client may explicitly use a -Mint-generated secret represented in the registry only by its fingerprint. Mint reads token -authority from its own server-side client registry rather than from the request. Its token endpoint -does not distinguish an unknown client, an authentication-method mismatch, a bad signature or -secret, a replayed assertion identifier, and an expired assertion, so it cannot probe the client -registry (REQ-SEC-G-016). - +Protected services verify access tokens from the deployment's configured OAuth issuer. Delegated evaluation, where one service asks a trusted peer to evaluate a claim, does not exist in this stack: no service holds such a surface, and REQ-SEC-G-010 is retired. diff --git a/docs/site/src/content/docs/spec/rs-arc-g.mdx b/docs/site/src/content/docs/spec/rs-arc-g.mdx index 47fece7669..61c6c38c38 100644 --- a/docs/site/src/content/docs/spec/rs-arc-g.mdx +++ b/docs/site/src/content/docs/spec/rs-arc-g.mdx @@ -9,7 +9,6 @@ source_repos: - registry-platform - registry-relay - registry-evidence - - registry-mint - solmara-lab last_reviewed: "2026-08-21" doc_type: specification @@ -105,7 +104,6 @@ flowchart TD subgraph support["Supporting components"] platform["Registry Platform
(registry-platform)
Shared Rust primitives: auth, OIDC,
audit, crypto, bounded read-only SQLite"] - mint["Registry Mint
(registry-mint)
Issues short-lived access tokens where
a deployment has no identity provider"] oid4vci["Wallet delivery front end
Speaks OID4VCI to a wallet;
signs nothing"] end @@ -116,14 +114,12 @@ flowchart TD relayctl -- "sealed package" --> relay platform -- "shared primitives" --> relay platform -- "shared primitives" --> evidence - platform -- "shared primitives" --> mint - mint -- "access token verified by" --> evidence evidence -- "signed assertion" --> oid4vci relay -. "published image" .-> solmara evidence -. "published image" .-> solmara ``` -This section restates the essential relationships as text. Registry Manifest produces a static discovery bundle for static publishers and for clients that inspect a description offline. Registry Platform supplies shared primitives to the runtime services, including the bounded read-only SQLite boundary. `relayctl` compiles an adopter's authoring project into a sealed package; a Relay process verifies that package and serves read-only consultation routes from it. Each Evidence Gateway evaluation answers one bounded question about one complete subject-role set from its own configured sources, and a request batch can group up to sixteen independent audience-scoped evaluations. Registry Mint issues the access tokens a resource server such as Evidence Gateway verifies, and that dependency runs one way only. The wallet delivery front end carries an Evidence-signed credential to a holder wallet over the one protocol Evidence declines to speak. Solmara Lab consumes published stack images in runnable demo topologies, but it is not a formal Registry Stack product. +This section restates the essential relationships as text. Registry Manifest produces a static discovery bundle for static publishers and for clients that inspect a description offline. Registry Platform supplies shared primitives to the runtime services, including the bounded read-only SQLite boundary. `relayctl` compiles an adopter's authoring project into a sealed package; a Relay process verifies that package and serves read-only consultation routes from it. Each Evidence Gateway evaluation answers one bounded question about one complete subject-role set from its own configured sources, and a request batch can group up to sixteen independent audience-scoped evaluations. The wallet delivery front end carries an Evidence-signed credential to a holder wallet over the one protocol Evidence declines to speak. Solmara Lab consumes published stack images in runnable demo topologies, but it is not a formal Registry Stack product. ## 3. Components @@ -153,15 +149,9 @@ Evidence Gateway (`registry-evidence`) is a single-crate Rust service and the `e Evidence Gateway Version 1 supports its declared holder-bound subject-binding mode and bounded issuance envelope, but has no credential lifecycle, no interactive holder-binding ceremony or issuance session, no status list, no revocation, no delegated or federated evaluation between peers, no policy decision point, no replay subsystem, and no worker or document subsystem. It does not depend on Relay, does not consult it, and does not read a metadata manifest. Evidence Gateway may consume a Relay-protected API through its ordinary fixed HTTP source contract, and that consumption grants it none of Relay's authorization model. -### Registry Mint - -Registry Mint (`registry-mint`) is a supporting service rather than a third runtime pattern: it is a minimal OAuth 2.0 token issuer and the `mint` binary, for deployments that have no identity provider of their own. A registered client authenticates with an RFC 7523 `private_key_jwt` client assertion by default, which is verified only against the keys registered for the client the assertion names. A standard authorization client that cannot sign assertions may explicitly select `client_secret_basic` or `client_secret_post`; the secret is verified only against that client's registered fingerprints and cannot be combined with Evidence authority or delegation. The authority claims carried by the minted access token are read from the server-side client registry and never from the assertion or token request. Issuer identity, signing keys, listener, and token policy are startup-only and immutable for the process lifetime; the client registry is reloadable, so onboarding, offboarding, and client credential rotation do not require restarting a resource server. Registry Mint evaluates no requirement, reads no registry source, and issues no assertion. - -The dependency runs one way. A resource server such as Evidence Gateway verifies a Mint-issued token as an ordinary OIDC access token, and Evidence Gateway carries no dependency on Registry Mint. In the workspace, `registry-mint` takes `registry-evidence` as a development dependency so that Mint's tests drive the Evidence Gateway authenticator against real tokens. - ### Wallet delivery front end -The wallet delivery front end (`registry-evidence-oid4vci`) is a supporting service in the same sense as Registry Mint. It speaks OID4VCI 1.0 Final to a holder wallet, the wallet-facing protocol Evidence Gateway deliberately refuses to speak, and delivers an Evidence-signed credential over it. It never signs a credential and holds no Evidence signing key. It never holds a holder private key: it receives holder public keys inside wallet-signed proofs and passes them to Evidence Gateway unchanged. It adds no Evidence semantics, so every authorization decision, every source acquisition, and every signature stays behind the Evidence Gateway runtime contract. +The wallet delivery front end (`registry-evidence-oid4vci`) is a supporting service outside the runtime product patterns. It speaks OID4VCI 1.0 Final to a holder wallet, the wallet-facing protocol Evidence Gateway deliberately refuses to speak, and delivers an Evidence-signed credential over it. It never signs a credential and holds no Evidence signing key. It never holds a holder private key: it receives holder public keys inside wallet-signed proofs and passes them to Evidence Gateway unchanged. It adds no Evidence semantics, so every authorization decision, every source acquisition, and every signature stays behind the Evidence Gateway runtime contract. Everything the service remembers lives in memory, bounded, for minutes. A restart therefore invalidates every outstanding offer, and single use is enforced per process. Both are documented limits of a single-replica deployment. @@ -191,11 +181,9 @@ The following ordered flow describes how a request moves through the stack from 5. **Serving.** A Relay process verifies the package by re-deriving it, opens its issuer, audit sink, source, and listener only after that verification succeeds, and serves read-only consultation routes. Every attempt is recorded to the audit chain before the source is read, and the request is refused when the audit sink cannot accept the record. -6. **Access-token issuance.** Where a deployment has no identity provider of its own, Registry Mint issues the short-lived access token the caller presents. A registered client authenticates with `private_key_jwt` by default, or a standard authorization client explicitly selects a compatible client-secret method, and Mint reads the token's authority claims from its server-side client registry. A deployment that already runs an identity provider skips this step entirely. +6. **Assertion.** Evidence Gateway authenticates the caller's access token against its one trusted issuer, matches exactly one authority path per evaluation, evaluates one predefined requirement over its own fixed bounded sources, and returns one signed minimum-disclosure assertion per available evaluation. A singular request carries one evaluation; a request batch groups up to sixteen ordered audience-scoped evaluations under one common requirement and purpose. Caller identity and authorization details may authorize or parameterize an evaluation, but do not become evidence. Each assertion carries the answer the requirement defines, not the source record behind it. -7. **Assertion.** Evidence Gateway authenticates the caller's access token against its one trusted issuer, matches exactly one authority path per evaluation, evaluates one predefined requirement over its own fixed bounded sources, and returns one signed minimum-disclosure assertion per available evaluation. A singular request carries one evaluation; a request batch groups up to sixteen ordered audience-scoped evaluations under one common requirement and purpose. Caller identity and authorization details may authorize or parameterize an evaluation, but do not become evidence. Each assertion carries the answer the requirement defines, not the source record behind it. - -8. **Wallet delivery.** Where a deployment enables it, the wallet delivery front end carries an Evidence-signed credential to a holder wallet over OID4VCI. It adds no claim and no signature of its own. +7. **Wallet delivery.** Where a deployment enables it, the wallet delivery front end carries an Evidence-signed credential to a holder wallet over OID4VCI. It adds no claim and no signature of its own. ## 5. Architectural invariants @@ -209,7 +197,7 @@ REQ-ARC-G-003: A runtime service MUST NOT expose a source-registry data mutation REQ-ARC-G-004: Every request that touches person-level data MUST be audited. Registry Platform provides the audit envelope primitives, verified to exist in the shared crate; Relay and Evidence Gateway MUST compose those primitives on routes that return person-level records or evidence assertions. This requirement states an invariant a conforming deployment meets; it is not a claim that every route in a given build has been individually audited. -REQ-ARC-G-005: A primitive that must behave identically across runtime services (authentication, OIDC, audit, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers, bounded read-only SQLite access) SHOULD be sourced from Registry Platform rather than reimplemented in Relay, Evidence Gateway, or Registry Mint. This keeps cross-service security behavior consistent and auditable in one place. +REQ-ARC-G-005: A primitive that must behave identically across runtime services (authentication, OIDC, audit, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers, bounded read-only SQLite access) SHOULD be sourced from Registry Platform rather than reimplemented in Relay, Evidence Gateway. This keeps cross-service security behavior consistent and auditable in one place. REQ-ARC-G-007: Evidence Gateway MUST own requirement evaluation, disclosure shape, and assertion signing. Relay MUST NOT evaluate a requirement or sign an evidence assertion. Where Evidence Gateway consumes a Relay-protected API, it MUST do so through its ordinary fixed HTTP source contract and MUST NOT inherit Relay's authorization model. @@ -219,8 +207,6 @@ REQ-ARC-G-014: Evidence Gateway MUST return only the answer its predefined requi REQ-ARC-G-015: Every signed Evidence Gateway assertion, whether returned directly or nested in a request-batch envelope, MUST be a flattened JWS JSON document over the exact evidence payload, and a signing failure MUST fail closed rather than release unsigned output. Where a deployment enables the SD-JWT VC serialization (`application/dc+sd-jwt`), that serialization MUST project the identical payload and MUST NOT be read as a credential lifecycle: Evidence Gateway Version 1 defines no issuance session, no holder-binding ceremony, no status list, no revocation, and no presentation verification. -REQ-ARC-G-016: The dependency between Registry Mint and a resource server MUST run one way. Registry Mint issues an access token that a resource server such as Evidence Gateway verifies as an ordinary OIDC access token; Evidence Gateway MUST NOT depend on Registry Mint, and a deployment that already operates an identity provider MUST be able to run Evidence Gateway without Registry Mint. - REQ-ARC-G-017: An Evidence Gateway request batch MUST preserve the boundary of each independent audience-scoped evaluation. The service MUST validate and authorize every item before source access, return results in request order, sign each available assertion independently, and release the complete response or nothing. An unavailable item MAY be represented only by its closed positional sentinel; any other item failure MUST abort the complete request. Request batching several subject sets MUST remain distinct from holder-bound issuance batching several credentials for one subject evaluation. An optional source-batch optimization MAY reduce physical HTTP calls but MUST NOT transfer authorization, source transport, result association, evidence construction, signing, or audit authority out of the Rust core. REQ-ARC-G-018: A Relay deployment MUST serve only from a sealed package it verified before opening any other resource, and verification MUST re-derive the compiled model and every generated artifact from the authored inputs rather than trust a recorded digest. The package digest is an integrity check, not an authenticity proof. A Relay process MUST NOT expose an administrative route, a reload route, or any other surface that changes governed meaning while the process runs. @@ -250,8 +236,7 @@ else. The sequence is ordered by increasing commitment rather than by data dependency. Describing a registry costs nothing and can be reviewed offline. Exposing it commits an institution to a governed contract that a reviewer can read before a listener opens. Asserting commits it to a signed answer about a live record. Registry Platform supports the runtime services with the shared primitives that make each stage -auditable, Registry Mint supplies access tokens where a deployment has no identity provider, and the -wallet delivery front end carries a signed credential to a holder wallet. The external Solmara Lab +auditable, and the wallet delivery front end carries a signed credential to a holder wallet. The external Solmara Lab adopter demo exercises the sequence using published stack images. ## Conformance @@ -267,7 +252,6 @@ An implementation conforms to this specification when it respects the following - REQ-ARC-G-010: discovery artifacts are treated as description, not as authorization, enforcement, or proof that a record exists. - REQ-ARC-G-014: an Evidence Gateway response carries the defined answer and not the source record behind it. - REQ-ARC-G-015: a signed response is a flattened JWS JSON document, signing failure fails closed, and the SD-JWT VC serialization projects the same payload without adding a credential lifecycle. -- REQ-ARC-G-016: the Registry Mint dependency runs one way, and Evidence Gateway runs without Registry Mint where an identity provider already exists. - REQ-ARC-G-017: request batching preserves independent evaluation boundaries, ordered results, per-assertion signing, atomic release, and Rust ownership of source and result association. - REQ-ARC-G-018: a Relay deployment serves only from a re-derived sealed package and exposes no administrative plane. - REQ-ARC-G-019: authoring and serving are separate processes over one shared library. @@ -302,7 +286,6 @@ This specification is `verified`: it is distilled from published artifacts a rea - The [boundary map](../../map/boundaries-and-map/) records each component's boundaries with their source citations. It is a distillation source for the components (Section 3) and the invariants (Section 5). - The [architecture overview](../../explanation/architecture/) gives the narrative data and contract flow that Section 4 makes precise. - The [relayctl reference](../../reference/relayctl/) and the [Evidence Gateway API reference](../../reference/apis/registry-evidence/) show the surfaces named in Section 3. -- The [Registry Mint reference](../../reference/mint/) records the token issuer described in Section 3. - The [standards register](../../reference/standards/) records the adoption mode for each standard listed in `standards_referenced`. ## Next diff --git a/docs/site/src/content/docs/spec/rs-op-posture.mdx b/docs/site/src/content/docs/spec/rs-op-posture.mdx index a37379141d..21aaab469c 100644 --- a/docs/site/src/content/docs/spec/rs-op-posture.mdx +++ b/docs/site/src/content/docs/spec/rs-op-posture.mdx @@ -61,7 +61,7 @@ not define authoring, compilation, or packaging, which belong to [RS-PR-RELAYCTL](../rs-pr-relayctl/). It does not define the security model or the threat boundary, which belong to [RS-SEC-G](../rs-sec-g/). -Evidence Gateway and Registry Mint are out of scope. Each exposes liveness and readiness probes of +Evidence Gateway is out of scope. It exposes liveness and readiness probes of its own, and neither is governed by the requirements in this specification. An operational probe is an observation, not an authorization decision, a conformance claim, or a diff --git a/docs/site/src/content/docs/spec/rs-sec-g.mdx b/docs/site/src/content/docs/spec/rs-sec-g.mdx index 2b00b90fcd..6ddf0b4773 100644 --- a/docs/site/src/content/docs/spec/rs-sec-g.mdx +++ b/docs/site/src/content/docs/spec/rs-sec-g.mdx @@ -7,7 +7,6 @@ source_repos: - registry-platform - registry-relay - registry-evidence - - registry-mint last_reviewed: "2026-09-06" doc_type: specification doc_id: RS-SEC-G @@ -58,7 +57,7 @@ This specification covers the security model that holds across the runtime servi This specification does not define: -- **Exact configuration schemas, algorithms, and parameters.** Concrete signing algorithms and key sizes, configurable token and cache windows, entropy floors, the precise security-header set, and the precise outbound allow and deny rules are configuration and operational detail, not contract data. For the configurable surface, see [Configure Relay](../../configure/relay/), [Configure Evidence Gateway](../../configure/evidence/), and [Configure Registry Mint](../../configure/mint/). +- **Exact configuration schemas, algorithms, and parameters.** Concrete signing algorithms and key sizes, configurable token and cache windows, entropy floors, the precise security-header set, and the precise outbound allow and deny rules are configuration and operational detail, not contract data. For the configurable surface, see [Configure Relay](../../configure/relay/), [Configure Evidence Gateway](../../configure/evidence/). - **Surface-specific protocol behavior.** How each service applies this model to its own routes, request shapes, and error codes belongs to [RS-PR-EVIDENCE](../rs-pr-evidence/) and [RS-PR-RELAY](../rs-pr-relay/). - **Key custody and secret provisioning.** How private keys and credentials are stored, injected, and rotated in a deployment (environment, file, hardware module) is an operator responsibility, addressed in Section 9. - **Deeper service-internal security mechanisms.** The internal structure of the audit envelope, and of any product replay state beyond the authority, binding, retention, and failure behavior Section 7 states, is reserved for product specifications and operator guidance. @@ -67,7 +66,7 @@ For the components named here and their boundaries, see [RS-ARC-G](../rs-arc-g/) ## 2. Shared security primitives -The registry stack concentrates its security-critical behavior in Registry Platform so that it behaves identically across services and can be reviewed in one place. Registry Platform supplies authentication helpers, OpenID Connect (OIDC) verification, audit envelopes, HTTP security, outbound HTTP policy, cryptography, and Selective Disclosure JWT Verifiable Credential (SD-JWT VC) helpers. Registry Relay, Evidence Gateway, and Registry Mint own how those primitives are configured and enforced on their own routes. +The registry stack concentrates its security-critical behavior in Registry Platform so that it behaves identically across services and can be reviewed in one place. Registry Platform supplies authentication helpers, OpenID Connect (OIDC) verification, audit envelopes, HTTP security, outbound HTTP policy, cryptography, and Selective Disclosure JWT Verifiable Credential (SD-JWT VC) helpers. Registry Relay and Evidence Gateway own how those primitives are configured and enforced on their own routes. REQ-SEC-G-001: A security-critical primitive that must behave identically across runtime services (authentication, OIDC verification, audit envelopes, HTTP security, outbound HTTP policy, cryptography, and SD-JWT VC helpers) SHOULD be sourced from Registry Platform rather than reimplemented per service. This is the security-model form of REQ-ARC-G-005, and it keeps cross-service security behavior consistent and auditable in one place. @@ -84,10 +83,9 @@ flowchart LR audit["Audit:
Platform envelope · fail-closed
principal · scopes · request_id · purpose"] caller --> authn --> authz --> serve --> audit keys["Verification keys:
issuer JWKS
(public half only)"] -.-> authn - mint["Registry Mint:
registered client authentication · one-way
issues the access token"] -.-> authn ``` -The diagram restates the model: a caller presents an access token the service verifies against its one configured issuer, the request is authorized against the scope its target requires before source or evaluation work, the service serves the configured surface, and the request is recorded. Verification keys are published so a verifier can check issued artifacts. Where a deployment has no identity provider, Registry Mint issues the access token the caller presents, and the resource server verifies it as an ordinary OIDC token. +The diagram restates the model: a caller presents an access token the service verifies against its one configured issuer, the request is authorized against the scope its target requires before source or evaluation work, the service serves the configured surface, and the request is recorded. Verification keys are published so a verifier can check issued artifacts. REQ-SEC-G-002: A runtime service MUST verify caller credentials against exactly one issuer, fixed by configuration at startup, and MUST authenticate every route that returns person-level records or evidence assertions before it produces a response. Registry Relay and Evidence Gateway both verify OIDC access tokens against one trusted issuer. Registry Relay MUST refuse to start when its compiled contract carries a protected access profile and no issuer is configured, so a deployment cannot serve a protected operation with caller verification switched off. @@ -118,7 +116,7 @@ A verifier needs the issuer's public key to check a signed artifact, and it need REQ-SEC-G-007: An issuer MUST sign with an asymmetric key and MUST publish only the public half through the issuer JWKS. Private key material MUST NOT be published and MUST NOT be required by a verifier. A key that is being rotated out MAY remain published for verification while artifacts it signed are still within their validity, so a verifier can check previously signed artifacts across a rotation. Evidence Gateway signs with exactly one active ES256/P-256 key, identifies service keys by their RFC 7638 thumbprints, and retains a previous public key for at least the maximum assertion validity plus the accepted clock skew. -The approved custody mechanism depends on assurance: local assurance uses a file-backed private JWK, while production and evidence-grade assurance require Vault/OpenBao Transit through a workload-local Unix-socket proxy. See [Configure Transit signing for Evidence Gateway and Registry Mint](../../tutorials/move-evidence-to-production-signing/). +The approved custody mechanism depends on assurance: local assurance uses a file-backed private JWK, while production and evidence-grade assurance require Vault/OpenBao Transit through a workload-local Unix-socket proxy. See [Configure Transit signing for Evidence Gateway](../../tutorials/move-evidence-to-production-signing/). Readiness, liveness, and protocol conformance checks show that a service has loaded configuration and can serve the expected protocol surface. They do not certify production-grade private-key custody. A deployment that uses software keys, local JWK files, or demo-generated keys can still be reachable and internally consistent; production custody, rotation, and approval of a key provider remain operator responsibilities under Section 9. @@ -132,46 +130,14 @@ REQ-SEC-G-009: A deployment MUST be able to run audit fail-closed, so that a req ## 7. Replay-protection authority -Replay protection is product correctness state, not a shared storage service. -Registry Platform can supply replay vocabulary and mechanism-only helpers, but each product defines -and holds its own replay decisions. -The [retention and persistent-state reference](../../operate/retention-and-persistent-state/) -defines the complete product retention inventory, and -[Operate Relay](../../operate/relay/) defines the Relay recovery procedure. +No maintained Registry Stack resource server holds a replay table, replay reservation, or +persistent single-use record. A service that keeps no replay state MUST NOT claim to consume a +credential or prevent its reuse. Replay prevention belongs to the surrounding protocol or issuer +that creates and validates a one-time challenge. -| Product | Protected execution or domain | Identity and request binding | Retention | -| --- | --- | --- | --- | -| Registry Mint | Single use of a client assertion identifier at the token endpoint. | The assertion's `jti` is remembered once its signature has been verified against the named client's registered keys, so a captured assertion cannot be spent twice inside its own lifetime. | The assertion's own expiry, bounded by the configured maximum assertion lifetime, in a per-process cache with a configured entry ceiling. | - -Registry Mint's client-assertion cache is the only replay authority this stack holds. No maintained -service persists a replay decision across restart, and none keeps replay correctness state in a -database. - -Registry Relay holds no replay state. Every compiled operation is a read, so no request it accepts -commits an effect a second identical request could commit twice. Its pagination cursors are -client-held and authenticated, bound to the source revision, contract revision, operation, access -profile, disclosure profile, and authorization context, and expiring against -`cursor.maximumAgeSeconds`. A cursor is bound request context, not a single-use token, and a -deployment MUST NOT present it as one. - -Evidence Gateway holds no replay state at all. Its request nonce is echoed in the response so a caller can -correlate its own request, and it is never stored, uniqueness-checked, or exposed elsewhere. -Evidence Gateway Version 1 therefore makes no single-use or freshness guarantee of its own, and a -deployment MUST NOT present the echoed nonce as one. - -REQ-SEC-G-018: A service that holds no replay state MUST NOT be presented as providing replay, -single-use, or freshness protection. A value such a service echoes, issues, or validates for another -purpose MUST NOT be documented, configured, or relied on as one of those guarantees, and a -deployment MUST NOT compose two such services into a claim neither one makes. -Registry Relay and Evidence Gateway both hold no replay state. - -REQ-SEC-G-017: A product that enforces single use in process rather than in persistent state MUST -bound the cache, MUST retain an identifier until at least its own expiry, and MUST fail closed when -the cache is saturated rather than evict a live entry, so that a caller cannot flush the cache and -then replay what it evicted. -Such enforcement is scoped to one process: a deployment that runs more than one instance MUST NOT -claim single use across those instances, and MUST NOT be documented as if it did. -Registry Mint's client-assertion cache is the only in-process enforcement in this stack. +REQ-SEC-G-018: A service that holds no replay state MUST NOT claim replay prevention or single-use +enforcement. It MAY validate signatures, expiry, audience, nonce equality, and other request +bindings, but those checks alone MUST NOT be described as consuming a credential. ## 8. Transport and outbound-call posture @@ -207,9 +173,9 @@ A registry stack deployment conforms to this specification when it: REQ-SEC-G-010 is retired. It required delegated evaluation to be static-peer only and fully verified before any Relay consultation or claim evaluation. The retired `registry-notary` component held the only delegated-evaluation surface in this stack, so no service here evaluates on behalf of a peer and nothing implements that requirement. Per RS-DOC REQ-DOC-008 the identifier is not reused. -REQ-SEC-G-003 is retired. It required a resource server's static bearer-credential mode to configure a `sha256:` fingerprint rather than the raw bearer secret. That mode shipped only in the retired Relay V1 runtime and its adopter tooling, so no maintained resource server implements the requirement and the identifier is not reused. Registry Mint's distinct token-endpoint client-secret compatibility method is governed by REQ-SEC-G-016; it authenticates a client before issuing a short-lived access token and grants no bearer access to a resource route by itself. +REQ-SEC-G-003 is retired. It required a resource server's static bearer-credential mode to configure a `sha256:` fingerprint rather than the raw bearer secret. That mode shipped only in the retired Relay V1 runtime and its adopter tooling, so no maintained resource server implements the requirement and the identifier is not reused. -REQ-SEC-G-014 and REQ-SEC-G-015 are retired together. They required a persisted replay decision to live in PostgreSQL state owned by the deciding product, forbade two products from sharing replay tables or correctness transactions, required product-owned atomic reservation and completion operations, and required a potentially stale recovery point to stay offline until product recovery rules passed. The retired Relay V1 runtime held the only persisted replay authority in this stack. Registry Relay V2 keeps no database of its own, Evidence Gateway holds no replay state, and Registry Mint enforces single use in a bounded per-process cache under REQ-SEC-G-017, so nothing implements those requirements. REQ-SEC-G-018 states what a service holding no replay state may claim. Per RS-DOC REQ-DOC-008 the identifiers are not reused. +REQ-SEC-G-014 and REQ-SEC-G-015 are retired together. They required a persisted replay decision to live in PostgreSQL state owned by the deciding product, forbade two products from sharing replay tables or correctness transactions, required product-owned atomic reservation and completion operations, and required a potentially stale recovery point to stay offline until product recovery rules passed. The retired Relay V1 runtime held the only persisted replay authority in this stack. Registry Relay V2 keeps no database of its own, Evidence Gateway holds no replay state, so nothing implements those requirements. REQ-SEC-G-018 states what a service holding no replay state may claim. Per RS-DOC REQ-DOC-008 the identifiers are not reused. Conformance to this specification does not imply conformance to any external standard cited in the `standards_referenced` frontmatter field, nor to OpenID Connect, OAuth 2.0, or any other authentication or authorization framework named in prose. Each cited standard's adoption mode and scope are documented in the [standards register](../../reference/standards/). @@ -218,14 +184,14 @@ Conformance to this specification does not imply conformance to any external sta This specification is `verified`: every requirement describes shipped behavior a reader can inspect, per RS-DOC REQ-DOC-014. - The [boundary map](../../map/boundaries-and-map/) records that the security primitives (authentication, OIDC, audit envelopes, HTTP security, outbound HTTP policy, cryptography, SD-JWT VC helpers) are owned by Registry Platform and that secret provisioning, audit retention, tenant isolation, deployment configuration, and incident response are operator responsibilities, which Sections 2 and 9 make precise. -- The [Evidence Gateway API reference](../../reference/apis/registry-evidence/), the [Registry Mint reference](../../reference/mint/), and [Configure Relay](../../configure/relay/) describe the OIDC trust inputs, the per-dataset, per-grant, and per-access-profile scopes, and the unauthenticated probes that Sections 3 and 4 state normatively. Relay publishes no shared API reference: its route set and its security requirements are described per deployment by the OpenAPI document the runtime serves, as [RS-PR-RELAY](../rs-pr-relay/) states. +- The [Evidence Gateway API reference](../../reference/apis/registry-evidence/), and [Configure Relay](../../configure/relay/) describe the OIDC trust inputs, the per-dataset, per-grant, and per-access-profile scopes, and the unauthenticated probes that Sections 3 and 4 state normatively. Relay publishes no shared API reference: its route set and its security requirements are described per deployment by the OpenAPI document the runtime serves, as [RS-PR-RELAY](../rs-pr-relay/) states. - The [Evidence Gateway security model](../../security/evidence/) and [Configure Evidence Gateway](../../configure/evidence/) describe JWKS publication, the public/private key boundary, and rotation, which Section 5 states normatively. - [RS-PR-EVIDENCE](../rs-pr-evidence/) and [RS-PR-RELAY](../rs-pr-relay/) carry the surface-level form of the authentication, authorization, key-publication, audit, and error requirements this document generalizes. - [RS-ARC-G](../rs-arc-g/) Section 5 holds the architectural invariants (REQ-ARC-G-004, REQ-ARC-G-005) that Sections 2 and 6 refine. - The [retention and persistent-state reference](../../operate/retention-and-persistent-state/) records the complete product retention inventory and states that Relay V2 keeps no database-backed correctness state, which is the inventory Section 7 states normatively. [Operate Relay](../../operate/relay/) carries the recovery procedure that replaced the retired backup and restore page. - The [standards register](../../reference/standards/) records the adoption mode for the standards listed in `standards_referenced`. -- The enforcing code is inspectable in the workspace: OIDC verification in `crates/registry-platform-oidc`, the Evidence Gateway authenticator in `crates/registry-evidence/src/auth.rs`, and the Relay authenticator in `crates/registry-relay-v2/src/auth.rs` (REQ-SEC-G-004); the Relay startup refusal that pairs a protected access profile with a configured issuer in `crates/registry-relay-v2/src/startup.rs` (REQ-SEC-G-002); registered client authentication and provisioning in `crates/registry-mint/src/assertion.rs`, `crates/registry-mint/src/clients.rs`, and `crates/registry-mint/src/client_secret.rs` (REQ-SEC-G-016); authority matching before source work in `crates/registry-evidence` and Relay's access-profile preflight in `crates/registry-relay-v2/src/api.rs` (REQ-SEC-G-005); the unauthenticated-surface allow-lists in Relay's router assembly (`crates/registry-relay-v2/src/server.rs`) and the Evidence Gateway router (`crates/registry-evidence/src/server.rs`), together with the `access.public_nonpublic_forbidden` and `disclosure.public_nonpublic_forbidden` compiler refusals in `crates/registry-relay-v2/src/compiler.rs` (REQ-SEC-G-006); the audit envelope in `crates/registry-platform-audit` and the services' fail-closed audit paths (REQ-SEC-G-008, REQ-SEC-G-009); Registry Mint's bounded assertion cache in `crates/registry-mint/src/replay.rs` (REQ-SEC-G-017); and the secret-material rejection lists in `crates/registry-manifest-core` (REQ-SEC-G-013). -- No maintained crate holds a replay table, a replay reservation, or a persistent single-use record: `crates/registry-relay-v2` and `crates/registry-relayctl` declare no database dependency beyond the read-only `crates/registry-platform-sqlite` boundary, and Registry Mint's only single-use state is the in-process cache REQ-SEC-G-017 bounds (REQ-SEC-G-018). +- The enforcing code is inspectable in the workspace: OIDC verification in `crates/registry-platform-oidc`, the Evidence Gateway authenticator in `crates/registry-evidence/src/auth.rs`, and the Relay authenticator in `crates/registry-relay-v2/src/auth.rs` (REQ-SEC-G-004); the Relay startup refusal that pairs a protected access profile with a configured issuer in `crates/registry-relay-v2/src/startup.rs` (REQ-SEC-G-002); authority matching before source work in `crates/registry-evidence` and Relay's access-profile preflight in `crates/registry-relay-v2/src/api.rs` (REQ-SEC-G-005); the unauthenticated-surface allow-lists in Relay's router assembly (`crates/registry-relay-v2/src/server.rs`) and the Evidence Gateway router (`crates/registry-evidence/src/server.rs`), together with the `access.public_nonpublic_forbidden` and `disclosure.public_nonpublic_forbidden` compiler refusals in `crates/registry-relay-v2/src/compiler.rs` (REQ-SEC-G-006); the audit envelope in `crates/registry-platform-audit` and the services' fail-closed audit paths (REQ-SEC-G-008, REQ-SEC-G-009); and the secret-material rejection lists in `crates/registry-manifest-core` (REQ-SEC-G-013). +- No maintained crate holds a replay table, replay reservation, or persistent single-use record (REQ-SEC-G-018). - The frozen Evidence Gateway Version 1 security invariant matrix (`products/evidence/contracts/security-invariant-matrix.yaml`) records the Evidence Gateway-specific form of the audit-ordering, fail-closed, transport-identity, and nonce invariants that Sections 3, 6, and 7 state at stack level. {/* Evidence: crates/registry-platform-oidc verifies bearer tokens for both services, and the @@ -233,8 +199,7 @@ This specification is `verified`: every requirement describes shipped behavior a crates/registry-relay-v2/src/auth.rs (REQ-SEC-G-004). crates/registry-relay-v2/src/startup.rs refuses to serve a protected access profile with no configured issuer (REQ-SEC-G-002), crates/registry-platform-audit carries the audit envelope both services append to - (REQ-SEC-G-008, REQ-SEC-G-009), and crates/registry-mint/src/replay.rs bounds the only single-use - state Registry Mint keeps (REQ-SEC-G-017, REQ-SEC-G-018). */} + (REQ-SEC-G-008, REQ-SEC-G-009). */} ## Next diff --git a/docs/site/src/content/docs/spec/rs-terms.mdx b/docs/site/src/content/docs/spec/rs-terms.mdx index 0c298d4cbf..36edac962f 100644 --- a/docs/site/src/content/docs/spec/rs-terms.mdx +++ b/docs/site/src/content/docs/spec/rs-terms.mdx @@ -7,7 +7,6 @@ source_repos: - registry-relay - registry-stack - registry-evidence - - registry-mint - registry-platform - registry-manifest - solmara-lab @@ -81,9 +80,7 @@ The registry stack comprises four formal products: Registry Platform, Registry R **Evidence Gateway** (`registry-evidence`): Single-crate minimum-disclosure assertion service and its `evidence` binary. Given authenticated authority, an authorized purpose, and a predefined requirement, it returns a signed assertion that answers the requirement rather than the source record. Evidence Gateway has no credential issuance lifecycle. -**Registry Mint** (`registry-mint`): Supporting service, not a fifth product pattern. It issues short-lived, audience-bound access tokens to registered machine clients so a resource server such as Evidence Gateway can require signed tokens without a general-purpose identity provider. The dependency runs one way only: Registry Mint's tests drive Evidence Gateway's authenticator, and Evidence Gateway does not depend on Registry Mint. - -**wallet delivery front end** (`registry-evidence-oid4vci`): Supporting service in the same sense as Registry Mint. It speaks OID4VCI 1.0 Final to a holder wallet and delivers an Evidence Gateway-signed credential over it. It signs no credential, holds no signing key and no holder private key, and adds no Evidence Gateway semantics of its own. +**wallet delivery front end** (`registry-evidence-oid4vci`): Supporting service outside the runtime product patterns. It speaks OID4VCI 1.0 Final to a holder wallet and delivers an Evidence Gateway-signed credential over it. It signs no credential, holds no signing key and no holder private key, and adds no Evidence Gateway semantics of its own. **Solmara Lab** (`solmara-lab`): Separately maintained adopter demo for the fictional Republic of Solmara. It runs published Registry Stack images in local and hosted topologies. Solmara Lab is not a formal Registry Stack product and has no conformance weight in the Registry Stack specifications. @@ -99,7 +96,7 @@ The registry stack comprises four formal products: Registry Platform, Registry R **minimum disclosure**: The rule that one Evidence Gateway response carries the smallest sufficient answer to one requirement and never the source record. The assertion payload is closed against additional properties, subject selector profiles and selector values MUST NOT appear in it, and the output gate admits only the exact concept identifiers and value forms the requirement declares. Registry Stack product term. -**access token**: The bearer credential a caller presents to Evidence Gateway. Evidence Gateway accepts one authentication kind whose issuer, audiences, accepted token types, algorithms, key set location, and claim names are fixed in the immutable bundle, and it reads authority only from those configured claim names. Registry Mint issues such tokens; any issuer writing the same claims serves equally well. Caller-supplied data outside the verified token MUST NOT create authority. +**access token**: The bearer credential a caller presents to Evidence Gateway. Evidence Gateway accepts one authentication kind whose issuer, audiences, accepted token types, algorithms, key set location, and claim names are fixed in the immutable bundle, and it reads authority only from those configured claim names. The deployment's configured issuer supplies such tokens. Caller-supplied data outside the verified token MUST NOT create authority. **subject binding**: The opaque, role-bound handle an assertion carries in place of a subject's selector values. It is derived by HMAC under a deployment secret over one canonical scope tuple: binding-key version, operator trust domain, binding scope, purpose, subject role, selector profile, and the complete canonical selector field set and its values. A relying party cannot recompute a binding under either mode and MUST pin the expected bindings from independent trusted state. Registry Stack product term. @@ -123,7 +120,7 @@ The registry stack comprises four formal products: Registry Platform, Registry R **OID4VCI**: OpenID for Verifiable Credential Issuance. The Evidence Gateway runtime implements it in no part, and OID4VCI in any part is a frozen Evidence Gateway Version 1 non-goal under both subject binding modes, so no serialization has a credential offer, pre-authorized code, authorization or token endpoint, `c_nonce`, credential endpoint, deferred issuance, or wallet grant. The separate `registry-evidence-oid4vci` front end speaks it, which is why it is a separate service. -**eSignet**: Open-source identity and authentication service (part of MOSIP). No stack service calls it. Evidence Gateway authenticates callers against one configured OIDC issuer and key set named in its immutable bundle and names no specific identity product. The integration runs the other way: a separately maintained eSignet provider authenticates a citizen against a Base Registry Engine (BReg) registry through one governed lookup carrying a Registry Mint token, which makes eSignet an ordinary governed client of a registry. See [Authenticate citizens with eSignet](../../explanation/esignet-authentication-over-breg/). Not a Registry Stack product term. +**eSignet**: Open-source identity and authentication service (part of MOSIP). No stack service calls it. Evidence Gateway authenticates callers against one configured OIDC issuer and key set named in its immutable bundle and names no specific identity product. The integration runs the other way: a separately maintained eSignet provider authenticates a citizen against a Base Registry Engine (BReg) registry through one governed lookup carrying a token from its configured OAuth issuer, which makes eSignet an ordinary governed client of a registry. See [Authenticate citizens with eSignet](../../explanation/esignet-authentication-over-breg/). Not a Registry Stack product term. ## 3. Metadata and standards terms @@ -173,7 +170,7 @@ The registry stack comprises four formal products: Registry Platform, Registry R **deployment project**: The directory an Evidence Gateway operator authors and mounts: a `runtime.yaml` file holding process-local bindings, and a `bundle/` directory holding the governed configuration, scripts, schemas, codelists, and fixtures. The bundle is loaded read-only at startup, so a new revision is a new deployment rather than a live change. Registry Stack product term. -**JWKS**: JSON Web Key Set. Evidence Gateway publishes its signing keys at `GET /.well-known/evidence/jwks.json` and Registry Mint publishes its own at a configured path, default `/.well-known/jwks.json`. Both are served without authentication so a verifier can obtain the public key without holding a credential. +**JWKS**: JSON Web Key Set. Evidence Gateway publishes its signing keys without authentication at `GET /.well-known/evidence/jwks.json` so a verifier can obtain the public key without holding a credential. **authoring project**: The mutable directory an adopter edits for one Relay deployment: a governed contract, the reviewed source schema observations, and the fixture cases that prove them, which `relayctl` compiles and seals into one Relay package. `relay` never serves an authoring project, only the sealed package built from it. A project describes exactly one Relay deployment and spans no other product; an Evidence Gateway deployment is authored separately as a deployment project. Registry Stack product term. diff --git a/docs/site/src/content/docs/start/breg-quickstart.mdx b/docs/site/src/content/docs/start/breg-quickstart.mdx index edca4201fe..857eaf8222 100644 --- a/docs/site/src/content/docs/start/breg-quickstart.mdx +++ b/docs/site/src/content/docs/start/breg-quickstart.mdx @@ -41,11 +41,11 @@ sequenceDiagram P-->>B: Return the rows the row boundary admits B->>P: Append one chained audit record B-->>A: Return the Registry Record envelope - Note over T: Your OpenID Connect provider, or Registry Mint + Note over T: Your OpenID Connect provider ``` -The token issuer is your OpenID Connect provider, or [Registry Mint](../../configure/mint/) when -you have none; Base Registry Engine verifies tokens and issues none. You provision PostgreSQL with +The token issuer is your OpenID Connect provider; Base Registry Engine verifies tokens and issues +none. You provision PostgreSQL with a migration role that owns the schema and a runtime role the serving process uses. The process verifies the active package at startup, serves the compiled routes, appends every admitted request to a hash-chained [audit journal](../../reference/glossary/#audit-journal), and keeps no state of @@ -57,7 +57,7 @@ its own outside PostgreSQL. ## Make your first request [Create and query your first registry](../../tutorials/first-breg/) starts disposable PostgreSQL -and Registry Mint on your machine, initializes a domain-neutral project with `bregctl init`, +and the maintained stock identity provider on your machine, initializes a domain-neutral project with `bregctl init`, activates an unsigned local package, and creates and reads one record over HTTP with a short-lived token. It lists the tools it needs and runs on the platforms in [platform support](../../explanation/known-limitations/#platform-support). @@ -107,7 +107,7 @@ and the problem codes. Base Registry Engine ships no business, facility, authority, permit, or asset model: every entity, route, and profile comes from the active package. It issues no tokens and stores no users; -identity belongs to your OpenID Connect provider or to Registry Mint. It is not a proxy over data +identity belongs to your OpenID Connect provider. It is not a proxy over data held elsewhere: it owns its records in its own PostgreSQL database, and Registry Relay and Evidence Gateway remain the products for sources that already exist. [How a configured registry works](../../explanation/configuration-defined-registry/) explains the diff --git a/docs/site/src/content/docs/start/casework.mdx b/docs/site/src/content/docs/start/casework.mdx index b3d695407c..52d9860879 100644 --- a/docs/site/src/content/docs/start/casework.mdx +++ b/docs/site/src/content/docs/start/casework.mdx @@ -74,11 +74,12 @@ caseworkctl init tutorial-work/casework --template standalone-decision caseworkctl dev tutorial-work/casework ``` -The installer verifies the release `SHA256SUMS` before it installs `casework`, `caseworkctl`, and -`mint` into `~/.local/bin`. The `standalone-decision` template writes a project that declares one +The installer verifies the release `SHA256SUMS` before it installs `casework` and `caseworkctl` +into `~/.local/bin`. The `standalone-decision` template writes a project that declares one decision kind and needs no registry behind it. `caseworkctl dev` starts PostgreSQL in a container, -starts Registry Mint and `casework` against it, seeds the directory, and prints a report carrying a -ready status, the service URL, the token endpoint, and one credential directory per local client. +starts the maintained stock identity provider and `casework`, seeds the directory, and prints a +report carrying a ready status, the service URL, the token endpoint, and one credential directory +per local client. [Decide your first work item](../../tutorials/first-casework/) takes it from there: submit one decision as a Requester, claim and decide it as Staff, and read the outcome back from the terminal @@ -114,8 +115,7 @@ system. Casework serves an API and renders no interface of its own. A browser never calls it directly: a staff host calls Casework server to server on a private network, which is why the API enables no -CORS. It issues no tokens and stores no users, so identity stays with your OpenID Connect provider, -or with [Registry Mint](../../configure/mint/) when you have none. +CORS. It issues no tokens and stores no users, so identity stays with your OpenID Connect provider. {/* Evidence: crates/registry-casework/src/http.rs, router and security_headers; crates/registry-casework/src/auth.rs, CaseworkAuthenticator; products/casework/README.md. */} diff --git a/docs/site/src/content/docs/start/evidence-quickstart.mdx b/docs/site/src/content/docs/start/evidence-quickstart.mdx index 93b7f240bc..2387742ae7 100644 --- a/docs/site/src/content/docs/start/evidence-quickstart.mdx +++ b/docs/site/src/content/docs/start/evidence-quickstart.mdx @@ -68,8 +68,8 @@ the same boundary with an unauthorized purpose and a modified signed response. When you are ready to use your own system, [draft an Evidence Gateway source from OpenAPI](../../tutorials/connect-an-institution-source/), then add only the [project-specific fixtures](../../tutorials/prove-an-evidence-project/) your reviewers need. -Build a reviewed candidate only after that local work is complete. Registry Mint is optional when -the deployment has no suitable OIDC issuer. +Build a reviewed candidate only after that local work is complete. Configure a compatible OpenID +Connect issuer before connecting callers. ### Consume Evidence Gateway diff --git a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx index 6f49149fe0..68aebae3ad 100644 --- a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx +++ b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx @@ -249,10 +249,10 @@ Return to the registry terminal and press `Ctrl+C`. ## If a local port is already in use -This tutorial needs three loopback ports: `8002` for `registry.py`, and `8080` and `8081` for -Evidence Gateway and Registry Mint. If another tutorial's services are still running, +This tutorial needs three loopback ports: `8002` for `registry.py`, plus the Evidence Gateway and +stock identity-provider ports. If another tutorial's services are still running, `evidencectl dev start .` cannot bind. Stop them with `evidencectl dev stop` in that project, or -start this one on two unused ports with `--evidence-port` and `--mint-port`. +start this one on two unused ports with `--evidence-port` and `--issuer-port`. Port `8002` appears twice inside `registry.py`, in the OpenAPI `servers` URL and in the listener, and again in the `--openapi` argument. Change all three before running `evidencectl init`, because diff --git a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx index 51b1fd7de2..440660a311 100644 --- a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx @@ -33,14 +33,13 @@ deploy a service for you. You need a released Evidence Gateway toolset on `PATH`, an editable project that runs locally, and an operator who can provision owner-only secrets and a private listener on the target host. -Use Registry Mint only when no suitable OpenID Connect (OIDC) issuer exists. An existing OIDC -issuer and Mint are equivalent choices from Evidence Gateway's perspective. +Configure a compatible OpenID Connect (OIDC) issuer for Evidence Gateway access tokens. A production or evidence-grade target also needs a Vault or OpenBao Transit key before you build, not after. The target's `governance.yaml` names one governed public JWK, and for these profiles that JWK is the public half of a non-exportable Transit key, so the key has to exist before the target is complete. Provision it with -[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/). +[Configure Transit signing for Evidence Gateway](../move-evidence-to-production-signing/). The build itself never contacts Transit; the workload-local proxy is what the target-host ceremony and the running service reach. @@ -136,7 +135,7 @@ governed service, authentication, authority, source, disclosure, or signing fiel `public-keys/` contains the exact active and published service JWKs named by `governance.yaml`. Production and evidence-grade targets bind the matching non-exportable provider key through the Transit signer in `runtime.yaml`. Use -[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) +[Configure Transit signing for Evidence Gateway](../move-evidence-to-production-signing/) before building the first strict candidate. ## Build the candidate @@ -266,7 +265,6 @@ rm -f "" ## Next - [Evidence authoring form reference](../../products/registry-evidence/authoring-form/) -- [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) - [Integrate an Evidence Gateway candidate with Docker Compose](../integrate-evidence-candidate-with-docker-compose/) - [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) - [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) diff --git a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx index dc33152930..86c13c9910 100644 --- a/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx +++ b/docs/site/src/content/docs/tutorials/connect-an-institution-source.mdx @@ -228,7 +228,7 @@ without copying its transport or credential policy. ## Run the authored project -Start the local Evidence Gateway and Registry Mint pair: +Start the local Evidence Gateway and stock identity provider: ```sh evidencectl dev start . diff --git a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx index 7019cc2e0c..70e697e36b 100644 --- a/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx +++ b/docs/site/src/content/docs/tutorials/control-who-can-request-evidence.mdx @@ -428,5 +428,5 @@ Return to the registry terminal and press `Ctrl+C`. - [Evidence authoring form reference](../../products/registry-evidence/authoring-form/) - [See Evidence Gateway refuse unsafe requests](../refuse-unsafe-evidence-requests/) -- [Request an access token from application code](../../configure/request-an-access-token/) +- [Configure Evidence Gateway](../../configure/evidence/) - [Manage Evidence Gateway verifier trust](../manage-evidence-verifier-trust/) diff --git a/docs/site/src/content/docs/tutorials/deploy-evidence-from-breg.mdx b/docs/site/src/content/docs/tutorials/deploy-evidence-from-breg.mdx index 3e0fc51198..0cee282fd9 100644 --- a/docs/site/src/content/docs/tutorials/deploy-evidence-from-breg.mdx +++ b/docs/site/src/content/docs/tutorials/deploy-evidence-from-breg.mdx @@ -93,8 +93,7 @@ existing configuration, not an overlay. Review every environment-dependent field | Signing | The signing operator supplies the public JWK and exact non-exportable Transit key version and workload-local proxy binding. | | Runtime | The deployment operator supplies the absolute candidate, secret and audit paths, private listener, HTTPS reverse proxy and any private CA files. | -Use an existing compatible OIDC issuer when suitable. Registry Mint is optional -for an operated deployment. Its source registration and Evidence caller +Use a compatible OpenID Connect issuer for an operated deployment. Its source registration and Evidence caller registration have different audiences and grants. A source credential must not carry BReg operator privileges. diff --git a/docs/site/src/content/docs/tutorials/first-breg.mdx b/docs/site/src/content/docs/tutorials/first-breg.mdx index ca369dec53..c46f8bf251 100644 --- a/docs/site/src/content/docs/tutorials/first-breg.mdx +++ b/docs/site/src/content/docs/tutorials/first-breg.mdx @@ -30,21 +30,19 @@ Everything you keep goes into one directory, `tutorial-work`. /> {/* Evidence: crates/registry-bregctl/src/lib.rs, init_files(); - crates/registry-bregctl/src/dev/mod.rs, start(); - crates/registry-mint/src/main.rs. */} + crates/registry-bregctl/src/dev/mod.rs, start(). */} ## Install Base Registry Engine -Install `breg`, `bregctl`, and `mint`. Registry Mint issues the local access tokens this tutorial -uses, standing in for the identity provider a deployment would have: +Install `breg` and `bregctl`. The local development command starts the maintained stock identity +provider and registers the tutorial clients: ```sh curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash bregctl --version -mint --version ``` -The installer checks the three binaries against the release `SHA256SUMS` before anything reaches +The installer checks both binaries against the release `SHA256SUMS` before anything reaches `~/.local/bin`, and installs them together or not at all. Keep that directory on your `PATH`. @@ -153,8 +151,9 @@ The first start downloads the pinned PostgreSQL image, so it takes longer than t The command returns once the registry answers. Its report names the service URLs, package revision, and private credential file paths for each client. -`dev` started PostgreSQL in a container, registered the clients from `dev-clients.yaml` with -Registry Mint under a fresh key each, built a package, replayed `tests/journeys.yaml` against a +`dev` started PostgreSQL and the maintained stock identity provider in containers, registered the +clients from `dev-clients.yaml` under a fresh key each, built a package, replayed +`tests/journeys.yaml` against a throwaway database, activated the package, and started BReg at the address the report shows. The services keep running after the command returns, so this one terminal is enough. @@ -165,27 +164,22 @@ Run the same command again at any time to print the report again. ## Get a token -Save the registry address, then ask Mint for a token as the `operator` client and write it into a -file `curl` can send as an authorization header: +Save the registry address, then ask the local issuer for a token as the `operator` client. The +command writes a header file that `curl` can send: ```sh registry_url=http://127.0.0.1:8090 -credentials=tutorial-work/project/.breg/dev/credentials/operator -umask 077 -mint token --url http://127.0.0.1:8091/token \ - --client-id "$(cat "$credentials/client-id")" --key "$credentials/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/authorization.header +bregctl dev token tutorial-work/project operator +authorization_header=tutorial-work/project/.breg/dev/secrets/operator.header ``` -Success prints nothing. -`mint token` signs a request with the client's private key and posts it to the token endpoint, the -way an application would; the token carries the `operator` scopes and claims and lasts five minutes. +`bregctl dev token` requests a fresh token through the registered client and reports the private +header-file path. The token carries the `operator` scopes and claims and lasts five minutes. -The header file holds that token and is readable only by your user; keep it out of version control -and support messages. +The header file is readable only by your user; keep it out of version control and support messages. If a request returns `401` after a pause, [renew the token](#renew-an-expired-token) and retry. -{/* Evidence: crates/registry-mint/src/cli.rs; crates/registry-mint/src/main.rs; +{/* Evidence: crates/registry-bregctl/src/dev/mod.rs, fresh_token(); crates/registry-bregctl/src/dev/config.rs. */} ## Read the registry @@ -195,7 +189,7 @@ and BReg checks the token against them: ```sh curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ "$registry_url/v1/records/records?accessProfile=operator" | python3 -m json.tool ``` @@ -242,7 +236,7 @@ Create the business record and save the response: ```sh curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: tutorial-create-1' \ --data '{"data":{"code":"DEMO-002","label":"North Quay Engineering"}}' \ @@ -302,7 +296,7 @@ Ask for labels only: ```sh curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header --get \ + --header @"$authorization_header" --get \ --data-urlencode 'accessProfile=operator' \ --data-urlencode '$select=label' \ --data-urlencode '$top=10' \ @@ -317,7 +311,7 @@ Try a filter on `label`: ```sh curl --silent --show-error \ - --header @tutorial-work/authorization.header --get \ + --header @"$authorization_header" --get \ --data-urlencode 'accessProfile=operator' \ --data-urlencode "\$filter=label eq 'North Quay Engineering'" \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ @@ -342,7 +336,7 @@ Repeat the request on `code`, which is filterable, and match the code you create ```sh curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header --get \ + --header @"$authorization_header" --get \ --data-urlencode 'accessProfile=operator' \ --data-urlencode "\$filter=code eq 'DEMO-002'" \ "$registry_url/v1/records/records" | python3 -m json.tool @@ -364,7 +358,7 @@ Read the created record and save its HTTP headers: ```sh record_id=$(python3 -c 'import json; print(json.load(open("tutorial-work/created.json"))["data"]["recordIdentifier"])') curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ --dump-header tutorial-work/record.headers \ "$registry_url/v1/records/records/$record_id?accessProfile=operator" | python3 -m json.tool ``` @@ -381,7 +375,7 @@ own before writing: ```sh curl --silent --show-error --fail-with-body \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ --header 'Content-Type: application/json-patch+json' \ --header 'Idempotency-Key: tutorial-patch-1' \ --header "If-Match: $record_etag" \ @@ -397,7 +391,7 @@ Try a different update while still using the old ETag: ```sh curl --silent --show-error \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ --header 'Content-Type: application/json-patch+json' \ --header 'Idempotency-Key: tutorial-patch-2' \ --header "If-Match: $record_etag" \ @@ -424,7 +418,7 @@ Omit the required `code` field: ```sh curl --silent --show-error \ - --header @tutorial-work/authorization.header \ + --header @"$authorization_header" \ --header 'Content-Type: application/json' \ --header 'Idempotency-Key: tutorial-missing-code' \ --data '{"data":{"label":"South Harbour Logistics"}}' \ @@ -486,9 +480,9 @@ If it fails, the registry is stopped: start it again with the command from | Symptom | Next move | | --- | --- | -| `bregctl`, `breg`, or `mint` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | -| `bregctl dev` refuses over a reported version | The `breg` or `mint` it resolved comes from another release than `bregctl`. The refusal names both versions. Install all three from the same release, or put the matching build first on `PATH`. | -| `bregctl dev` refuses a port | Something else listens on 8090, 8091, or 55432. Pass `--breg-port`, `--mint-port`, or `--database-port` on the first start; later starts keep the ports you chose. | +| `bregctl` or `breg` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | +| `bregctl dev` refuses over a reported version | The `breg` it resolved comes from another release than `bregctl`. Install both from the same release, or put the matching build first on `PATH`. | +| `bregctl dev` refuses a port | Something else listens on 8090, 8091, or 55432. Pass `--breg-port`, `--issuer-port`, or `--database-port` on the first start; later starts keep the ports you chose. | | `bregctl dev` fails before it reports `ready` | Read the refusal: it names the check that failed, such as `test.step.failed` with the journey step's own message. Otherwise check that Docker is running. The private logs are under `tutorial-work/project/.breg/dev/logs/`; do not share the credential or secret files beside them. | | A create returns `409` with code `idempotency.conflict` | The key was already used with a different body. Reuse the exact body, or choose a new key for a new write. | | A create returns `409` with code `mutation.conflict` | The code is already taken; codes are unique in this project. Choose a new code and a new key. | diff --git a/docs/site/src/content/docs/tutorials/first-casework.mdx b/docs/site/src/content/docs/tutorials/first-casework.mdx index 283ae206cd..d7a4195254 100644 --- a/docs/site/src/content/docs/tutorials/first-casework.mdx +++ b/docs/site/src/content/docs/tutorials/first-casework.mdx @@ -31,22 +31,19 @@ Everything you keep goes into one directory, `tutorial-work`. /> {/* Evidence: crates/registry-caseworkctl/src/project.rs, init(); - crates/registry-caseworkctl/src/dev/mod.rs, start(); - crates/registry-mint/src/main.rs. */} + crates/registry-caseworkctl/src/dev/mod.rs, start(). */} ## Install Registry Casework -Install `casework`, `caseworkctl`, and `mint`. -[Registry Mint](../../reference/glossary/#registry-mint) issues the local access tokens this -tutorial uses, standing in for the identity provider a deployment would have: +Install `casework` and `caseworkctl`. The local development command starts the maintained stock +identity provider and registers the tutorial clients: ```sh curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash caseworkctl --version -mint --version ``` -The installer checks the three binaries against the release `SHA256SUMS` before anything reaches +The installer checks both binaries against the release `SHA256SUMS` before anything reaches `~/.local/bin`, and installs them together or not at all. Keep that directory on your `PATH`. @@ -124,23 +121,23 @@ caseworkctl dev tutorial-work/casework The first start downloads the pinned PostgreSQL image, so it takes longer than the later ones. The command returns once Casework answers, with `status: ready` in its report. -`dev` started PostgreSQL in a container, registered the four clients from `dev-clients.yaml` with -Mint under a fresh key each, wrote an operator file, migrated the database, started `casework`, and +`dev` started PostgreSQL and the maintained stock identity provider in containers, registered the +four clients from `dev-clients.yaml` under a fresh key each, wrote an operator file, migrated the +database, started `casework`, and seeded the [directory](../../reference/glossary/#directory) from the same clients file, which the report confirms under `directory`. -The report also names the Casework address, the Mint token endpoint, the audience, the runtime -journal, and the credential file paths for each client. +The report also names the Casework address, the issuer, the token endpoint, the audience, the +runtime journal, and the credential file paths for each client. The services keep running after the command returns, so this one terminal is enough. Use the two addresses printed in the report. These assignments use the default ports or the -`CASEWORKCTL_DEV_CASEWORK_PORT` and `CASEWORKCTL_DEV_MINT_PORT` overrides; replace the URLs if the -report differs: +`CASEWORKCTL_DEV_CASEWORK_PORT` and `CASEWORKCTL_DEV_ISSUER_PORT` overrides; replace the URL if +the report differs: ```sh casework_url="http://127.0.0.1:${CASEWORKCTL_DEV_CASEWORK_PORT:-8092}" -token_endpoint="http://127.0.0.1:${CASEWORKCTL_DEV_MINT_PORT:-8093}/token" -credentials=tutorial-work/casework/.casework/dev/credentials +headers=tutorial-work/casework/.casework/dev/secrets ``` Keep this terminal open so the values remain available to the later commands. @@ -151,23 +148,19 @@ Keep this terminal open so the values remain available to the later commands. ## Submit a request as the Requester -Ask Mint for a token as the `requester` client and write it into a file `curl` can send as an -authorization header: +Ask the local issuer for a token as the `requester` client. The command writes an owner-only header +file that `curl` can send: ```sh umask 077 -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/requester/client-id")" \ - --key "$credentials/requester/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.header +caseworkctl dev token tutorial-work/casework requester ``` -Success prints nothing. -`mint token` signs a request with the client's private key and posts it to the token endpoint, the -way an application would; the token carries the `casework:request` scope and lasts five minutes. +`caseworkctl dev token` requests a fresh token through the registered client and reports the private +header-file path. The token carries the `casework:request` scope and lasts five minutes. :::caution[Header files hold live tokens] -`tutorial-work/requester.header` and the other header files written later each hold a bearer token +`$headers/requester.header` and the other header files written later each hold a bearer token that acts as that client until it expires. `umask 077` makes them readable only by your user. Keep them out of version control, screenshots, and support messages. @@ -177,7 +170,7 @@ Submit the renewal: ```sh curl --silent --show-error \ - --header @tutorial-work/requester.header \ + --header @"$headers/requester.header" \ --header 'Registry-Casework-Profile: requester' \ --header 'Idempotency-Key: tutorial-renewal-1' \ --header 'Content-Type: application/json' \ @@ -228,24 +221,21 @@ own request later. {/* Evidence: crates/registry-casework/src/http.rs, create_hosted_item() and idempotency_key(); crates/registry-casework/src/hosted.rs, create_hosted_item(); crates/registry-casework-core/src/hosted.rs, HostedCreateRequest; - crates/registry-mint/src/cli.rs. */} + crates/registry-caseworkctl/src/dev/mod.rs, fresh_token(). */} ## Open the inbox as Staff -Mint a second token, this time as the `staff` client: +Request a second token, this time as the `staff` client: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token tutorial-work/casework staff ``` List the [work items](../../reference/glossary/#work-item) the Staff client's teams serve: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --header 'Registry-Casework-Profile: staff' \ --output tutorial-work/inbox.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions&limit=25" @@ -341,10 +331,7 @@ Renew the Staff token before you act, so time spent reading the inbox cannot tur an authentication refusal: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token tutorial-work/casework staff ``` Take the identifier and the offered `ifMatch` value from the inbox rather than typing them: @@ -358,7 +345,7 @@ Claim it: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $claim_match" \ --header 'Idempotency-Key: tutorial-claim-1' \ @@ -411,7 +398,7 @@ python3 -c 'import json; item = json.load(open("tutorial-work/claimed.json"))["i } ``` -The holder is the token's issuer and subject, the identity Mint put in the token, and the revision +The holder is the token's issuer and subject, the identity provider's token identity, and the revision moved to 2. `actions` now offers the two declared outcomes beside `release` and `delegate`, each carrying the new `ifMatch`: claiming an item is what opens deciding it. @@ -425,17 +412,14 @@ new `ifMatch`: claiming an item is what opens deciding it. Renew the Staff token again before the two decision attempts: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token tutorial-work/casework staff ``` Try to confirm the renewal with the `ifMatch` value the inbox offered before the claim: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $claim_match" \ --header 'Idempotency-Key: tutorial-decide-1' \ @@ -463,7 +447,7 @@ Take the `ifMatch` value the claim offered for the `confirmed` outcome, and deci ```sh decide_match=$(python3 -c 'import json; actions = json.load(open("tutorial-work/claimed.json"))["item"]["actions"]; print(next(action["ifMatch"] for action in actions if action["operation"] == "confirmed"))') curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $decide_match" \ --header 'Idempotency-Key: tutorial-decide-1' \ @@ -514,17 +498,14 @@ Renew the Requester token before reading its own [terminal feed](../../reference/glossary/#terminal-feed): ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/requester/client-id")" \ - --key "$credentials/requester/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.header +caseworkctl dev token tutorial-work/casework requester ``` Read the feed: ```sh curl --silent --show-error \ - --header @tutorial-work/requester.header \ + --header @"$headers/requester.header" \ --header 'Registry-Casework-Profile: requester' \ --output tutorial-work/terminal.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/hosted-items/terminal" @@ -567,13 +548,10 @@ back to its own records needs, and the opaque `actorRef` in place of the casewor ## See who decided, as the Supervisor A Supervisor resolves the opaque handle to a person, one decision at a time. -Mint a Supervisor token: +Request a Supervisor token: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/supervisor/client-id")" \ - --key "$credentials/supervisor/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/supervisor.header +caseworkctl dev token tutorial-work/casework supervisor ``` Read the [accountability record](../../reference/glossary/#accountability-record) for the decision @@ -582,7 +560,7 @@ event: ```sh event_id=$(python3 -c 'import json; print(json.load(open("tutorial-work/decided.json"))["eventId"])') curl --silent --show-error \ - --header @tutorial-work/supervisor.header \ + --header @"$headers/supervisor.header" \ --header 'Registry-Casework-Profile: supervisor' \ --output tutorial-work/accountability.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/hosted-accountability/$event_id" @@ -630,17 +608,14 @@ Renew the Requester token so the first refusal proves the profile boundary rathe expiry: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/requester/client-id")" \ - --key "$credentials/requester/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.header +caseworkctl dev token tutorial-work/casework requester ``` Ask for the Staff inbox with the Requester token: ```sh curl --silent --show-error \ - --header @tutorial-work/requester.header \ + --header @"$headers/requester.header" \ --header 'Registry-Casework-Profile: requester' \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions" @@ -657,15 +632,12 @@ The Requester profile carries no authority over the queue, whatever it asks for. Renew the Staff token, then ask for the same inbox while selecting the `supervisor` profile: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token tutorial-work/casework staff ``` ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --header 'Registry-Casework-Profile: supervisor' \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions" @@ -686,7 +658,7 @@ Last, send the Staff request with no profile header at all: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @"$headers/staff.header" \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions" python3 -c 'import json; print(json.load(open("tutorial-work/problem.json"))["code"])' @@ -745,7 +717,7 @@ All of it came from the YAML in `tutorial-work/casework`. ### Renew an expired token A local token lasts five minutes. -Run the `mint token` command for that client again; it replaces the header file. +Run `caseworkctl dev token tutorial-work/casework ` again; it replaces that client's header file. If it fails, the runtime is stopped: start it again with the command from [Start Casework](#start-casework), then renew. @@ -753,15 +725,15 @@ If it fails, the runtime is stopped: start it again with the command from | Symptom | Next move | | --- | --- | -| `caseworkctl`, `casework`, or `mint` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | -| `caseworkctl dev` refuses over a reported version | The `casework` or `mint` it resolved comes from another release than `caseworkctl`. The refusal names both versions. Install all three from the same release, or put the matching build first on `PATH`. | -| `caseworkctl dev` refuses a port | Something else listens on 8092, 8093, or 55433. Set `CASEWORKCTL_DEV_CASEWORK_PORT`, `CASEWORKCTL_DEV_MINT_PORT`, or `CASEWORKCTL_DEV_DATABASE_PORT`, or pass `--casework-port`, `--mint-port`, or `--database-port`, on the first start; later starts keep the ports you chose, and the `casework_url` and `token_endpoint` assignments from [Start Casework](#start-casework) pick them up. | +| `caseworkctl` or `casework` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | +| `caseworkctl dev` refuses over a reported version | The `casework` it resolved comes from another release than `caseworkctl`. Install both from the same release, or put the matching build first on `PATH`. | +| `caseworkctl dev` refuses a port | Something else listens on 8092, 8093, or 55433. Set `CASEWORKCTL_DEV_CASEWORK_PORT`, `CASEWORKCTL_DEV_ISSUER_PORT`, or `CASEWORKCTL_DEV_DATABASE_PORT`, or pass `--casework-port`, `--issuer-port`, or `--database-port`, on the first start; later starts keep the ports you chose, and the `casework_url` and `token_endpoint` assignments from [Start Casework](#start-casework) pick them up. | | `caseworkctl dev` fails before it reports `ready` | Read the refusal: it names the check that failed. Otherwise check that Docker is running. Run `caseworkctl dev events tutorial-work/casework` for the retained runtime journal; do not share the credential files beside it. | -| A request returns `401` with code `authentication.refused` | The token expired, or the header file is stale. Mint it again for that client, as [Renew an expired token](#renew-an-expired-token) describes. | +| A request returns `401` with code `authentication.refused` | The token expired, or the header file is stale. Request it again for that client, as [Renew an expired token](#renew-an-expired-token) describes. | | A request returns `400` with code `request.invalid` | A required header is missing. Every request carries `Registry-Casework-Profile`, and every write also carries `Idempotency-Key`. | | A claim or decision returns `412` | The item moved. Re-read it, take the `ifMatch` value from the `actions` entry for the operation you want, and send it again. | | A claim returns `409` | Another caller holds the item. Its `actions` array shows what remains open to you. | -| You opened a fresh terminal | Run the `casework_url`, `token_endpoint`, and `credentials` assignments from [Start Casework](#start-casework) again. The header files and `tutorial-work` directory are still there. | +| You opened a fresh terminal | Run the `casework_url` and `headers` assignments from [Start Casework](#start-casework) again. The header files and `tutorial-work` directory are still there. | ## Next diff --git a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx index 6c56186f98..3f72b3e433 100644 --- a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx +++ b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx @@ -50,7 +50,7 @@ The proxy owns its provider auto-auth credential, provider trust file, and revie configuration. Do not mount those inputs into Evidence Gateway. The proxy configuration must force its auto-auth token, require the `X-Vault-Request` header, disable request retries, and set socket ownership for the Evidence Gateway process. Use -[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) +[Configure Transit signing for Evidence Gateway](../move-evidence-to-production-signing/) and the maintained deployment-target templates for the exact boundary. The Git-managed proxy HCL is nonsecret. Provider credentials and generated tokens remain outside @@ -200,6 +200,5 @@ and audit-chain verification remain operator responsibilities. ## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) -- [Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) -- [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) +- [Configure Transit signing for Evidence Gateway](../move-evidence-to-production-signing/) - [Configure Evidence Gateway](../../configure/evidence/) diff --git a/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx b/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx deleted file mode 100644 index 67455a80c4..0000000000 --- a/docs/site/src/content/docs/tutorials/issue-evidence-access-tokens-with-registry-mint.mdx +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: Issue Evidence Gateway access tokens with Registry Mint -description: Configure Registry Mint as an optional OIDC issuer for a completed Evidence Gateway candidate when the deployment has no suitable identity provider. -status: current -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-08-06" -doc_type: tutorial -persona: - - operator - - assertion provider -locale: en -standards_referenced: [] ---- - -import QuickstartMeta from '../../../components/QuickstartMeta.astro'; - -Start with the completed candidate from -[Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/). Use Registry Mint -when that deployment has no suitable OpenID Connect (OIDC) issuer for Evidence Gateway. Mint is a separate -supporting service. Evidence Gateway does not require Mint, and an external HTTPS OIDC issuer follows the -same Evidence Gateway authentication contract. - - - -## Before you start - -You need a completed Evidence Gateway candidate, a public HTTPS issuer name for Mint, a registered machine -caller, and released `mint` and `evidencectl` binaries. Mint serves one active process in Version 1. -Its client-assertion replay cache is memory-only and clears when the process restarts. Do not make a -high-availability or durable replay-protection claim for this deployment shape. - -Create Mint's signing key independently from Evidence Gateway's signing key. Strict deployments keep -the non-exportable private key in Vault or OpenBao Transit and expose only a workload-local Unix -socket to Mint. - -## Author Mint separately - -Create a complete Mint target beside the Evidence Gateway target in the deployment repository: - -```text -environments/production/mint/ - mint.yaml - clients/ - .yaml - public-keys/ - .jwk.json -``` - -Git contains the public service JWK and public client registrations. Keep the Mint audit HMAC key, -provider token, auto-auth credentials, client private keys, and issued tokens outside Git. - -Set Mint's `issuer` to its public HTTPS identity. Configure its listener on a private address and -let operator-controlled routing or split DNS resolve the public HTTPS issuer inside the private -network. Do not replace Evidence Gateway's issuer or JWKS URI with an internal plain-HTTP service name. - -Register each client with its public JWK, reviewed principal, requester tags, evidence audience, -and optional grant. Mint writes authority from this registration, never from the client's request. - -Set `validationMode: strict`. Configure `signing.activePublicJwkFile` with the exact public P-256 -JWK, and configure `signer.kind: transit` with the workload-local socket, Transit mount, key name, -pinned nonzero version, and bounded timeout. Follow -[Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) -to create the key, proxy identity, socket, and least-privilege policy. Mint receives no provider -token or private signing key. - -## Check the two configurations - -Start the workload-local Mint proxy, then validate Mint without opening a listener. `mint check` -performs the provider metadata and sign-and-verify self-test, so a strict configuration fails when -the proxy is unavailable: - -```sh -mint check --config "/environments/production/mint/mint.yaml" -``` - -Then compare the completed Evidence Gateway candidate with Mint. This check is read-only and does not copy -or modify either project: - -```sh -evidencectl doctor \ - --project "" \ - --mint-config "/environments/production/mint/mint.yaml" -``` - -The paired check compares issuer, JWKS URI, audiences, admitted algorithm and token type, plus the -principal, requester-tags, evidence audience, grant-id, grant-authority, and optional actor claim -names. It does not decide whether a client has legal authority, create an authority profile, or -register a client. - -## Start Mint and obtain a token - -Start Mint behind operator-controlled TLS, keeping the Mint listener private: - -```sh -mint serve --config "/environments/production/mint/mint.yaml" -``` - -Route token requests only after Mint's `/ready` endpoint reports ready. - -Use a registered caller's own private JWK to request one access token over the public HTTPS -identity. The token is written to standard output only. Store it in an owner-only local file for -the next request, never in the candidate, a fixture, a log, or a command argument. - -```sh -umask 077 -mint token \ - --url "https:///token" \ - --client-id "" \ - --key "" > "" -``` - -Send the token to Evidence Gateway through its real HTTPS boundary using the owner-only Curl configuration -described in [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/). -That request proves the selected client-to-grant path. The paired doctor check does not. - -## Expected result - -Mint accepts the reviewed client assertion and Evidence Gateway accepts the resulting `at+jwt` access token -over its HTTPS boundary. Retain the Evidence Gateway response and audit verification result, not the token. - -## Clean up the token file - -Remove the temporary token file after the synthetic deployment request. Do not retain it in a -candidate, fixture, audit record, or shell history. - -```sh -rm -f "" -``` - -## Next - -- [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) -- [Configure Transit signing for Evidence Gateway and Registry Mint](../move-evidence-to-production-signing/) -- [Configure Registry Mint](../../configure/mint/) -- [Registry Mint reference](../../reference/mint/) diff --git a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx index 7a8e3bb757..2a7bac1ffa 100644 --- a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx +++ b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx @@ -615,7 +615,7 @@ accredited. ## Start the project -Compile the two questions and start Evidence Gateway and Registry Mint: +Compile the two questions and start Evidence Gateway with its stock identity provider: ```sh evidencectl dev start . diff --git a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx index 35ba8797bf..2d59881126 100644 --- a/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx +++ b/docs/site/src/content/docs/tutorials/move-evidence-to-production-signing.mdx @@ -1,6 +1,6 @@ --- -title: Configure Transit signing for Evidence Gateway and Registry Mint -description: Configure non-exportable P-256 signing through a workload-local Vault or OpenBao Transit proxy without giving provider credentials to either service. +title: Configure Transit signing for Evidence Gateway +description: Configure non-exportable P-256 signing through a workload-local Vault or OpenBao Transit proxy without giving provider credentials to the service. status: current owner: registry-docs source_repos: @@ -13,8 +13,8 @@ locale: en standards_referenced: [] --- -Use this procedure to move Evidence Gateway or Registry Mint from disposable local signing to a -strict deployment. Each service signs through its own workload-local Unix-socket proxy. The service +Use this procedure to move Evidence Gateway from disposable local signing to a strict deployment. +The service signs through its own workload-local Unix-socket proxy. The service process receives no Vault or OpenBao token, and the provider retains the private key. ## Prerequisites @@ -22,13 +22,13 @@ process receives no Vault or OpenBao token, and the provider retains the private You need: - A Vault or OpenBao Transit mount administered outside the application workload. -- A separate provider identity and Transit key for each service and environment. +- A separate provider identity and Transit key for each environment. - An auto-auth method appropriate for the deployment platform. - A reviewed deployment target based on the ready-to-copy templates under `products/evidence/reference/deployment-targets/`. - Python 3.11 or later, run through [`uv`](https://docs.astral.sh/uv/), to convert each service's public key from PEM to JWK and compute its RFC 7638 SHA-256 thumbprint. [Publish the exact public - projections](#publish-the-exact-public-projections) gives the exact command for each service. + projections](#publish-the-exact-public-projections) gives the exact command for Evidence Gateway. The commands use a Transit mount named `transit`. Change the mount consistently when your provider uses another name. @@ -36,7 +36,7 @@ uses another name. ## Create one key per service Run the provider administration commands through an authenticated operator session, not from the -Evidence Gateway or Mint container. +Evidence Gateway container. Settle three things before you create the key. Decide which provider instance and mount hold this signing identity for as long as the key is in service. Confirm that the provider's own backup and @@ -48,8 +48,8 @@ identity authorized to rotate it, in the deployment record rather than in applic A key created with `exportable=false` and `allow_plaintext_backup=false` never releases its private material, and the pair is one-way in the permissive direction only: a provider administrator can set either field to `true` on this key later, and Vault and OpenBao then refuse to set it back to -`false`. Evidence Gateway and Mint accept a Transit key only while both fields read `false`, so -exporting the key retires that key name from both services permanently, for every version under it. +`false`. Evidence Gateway accepts a Transit key only while both fields read `false`, so exporting the key +retires that key name permanently, for every version under it. There is no supported way to move this private key to another key manager or to sign with it outside the provider. The recovery path is a new key and [rotating the signing key](../rotate-evidence-signing-keys/), not export. @@ -63,12 +63,6 @@ vault write transit/keys/evidence-signing \ derived=false \ exportable=false \ allow_plaintext_backup=false - -vault write transit/keys/mint-signing \ - type=ecdsa-p256 \ - derived=false \ - exportable=false \ - allow_plaintext_backup=false ``` Use the equivalent `bao` commands for OpenBao. The @@ -80,7 +74,6 @@ Read each key's metadata and record the nonzero version that the application wil ```sh vault read -format=json transit/keys/evidence-signing > "/evidence-key.json" -vault read -format=json transit/keys/mint-signing > "/mint-key.json" ``` The reviewed metadata must report `type: ecdsa-p256`, `derived: false`, `exportable: false`, @@ -144,20 +137,17 @@ PY } pem_to_jwk "/evidence-key.json" "" "environments//evidence/public-keys" -pem_to_jwk "/mint-key.json" "" "environments//mint/public-keys" ``` Each command prints the thumbprint it used as the filename. The result lands in the same -environment target either command would need to publish to: +environment target the command publishes to: ```text environments// evidence/public-keys/.jwk.json - mint/public-keys/.jwk.json ``` -Keep Evidence Gateway signing, Mint signing, Evidence Gateway audit, Mint audit, subject binding, -and client keys distinct. Run the target's `check-public-key-separation.sh` before review when you +Keep Evidence Gateway signing, audit, subject-binding, and client keys distinct. Run the target's `check-public-key-separation.sh` before review when you start from the maintained deployment-target templates. ## Configure the governed key and runtime signer @@ -188,25 +178,7 @@ signer: timeoutMilliseconds: 2000 ``` -Registry Mint keeps both blocks in `mint.yaml`: - -```yaml -validationMode: strict -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: - timeoutMilliseconds: 2000 -``` - -Do not put a provider token, auto-auth credential, or private JWK in either configuration. +Do not put a provider token, auto-auth credential, or private JWK in this configuration. ## Isolate provider access in local proxies @@ -230,8 +202,8 @@ path "transit/sign/evidence-signing/sha2-256" { } ``` -Use a corresponding policy for `mint-signing`. The parameter constraints make the service identity -usable only for the pinned version and exact JWS signing request shape. During a planned rotation, +The parameter constraints make the service identity usable only for the pinned version and exact +JWS signing request shape. During a planned rotation, temporarily allow the old and new numeric versions, for example `"key_version" = [7, 8]`, then remove the retired version after the overlap window. The provider's `min_encryption_version` provides a second retirement control. @@ -266,10 +238,9 @@ reaches the provider through this socket. Under the weaker `true`, the proxy att token only when the request carries none, and a token supplied by whatever reached the socket is used instead. -Add the deployment's reviewed `auto_auth` method and provider trust settings. Use a separate socket, -identity, and configuration for Mint. +Add the deployment's reviewed `auto_auth` method and provider trust settings. -Configure one proxy per service to: +Configure the proxy to: - Listen only on the service-specific Unix socket. - Force its auto-auth token for proxied requests. @@ -287,20 +258,18 @@ defines the retry and request-header controls. OpenBao deployments use the corre ## Verify before routing traffic -Start each proxy before checking its service. Run the checks from the final execution context so +Start the proxy before checking Evidence Gateway. Run the checks from the final execution context so the commands see the same socket, public keys, paths, ownership, and secret roots as the service: ```sh evidence check --runtime "/runtime.yaml" -mint check --config "/mint.yaml" ``` Each check reads provider metadata, verifies the pinned version and custody controls, compares the provider public key with the governed JWK, and performs a sign-and-verify self-test. A mismatch, timeout, malformed response, or unavailable proxy fails the check. -Start the services only after the checks pass. Route requests only after both `/ready` endpoints -report ready. If provider access fails later, signing fails closed and readiness reports the signer +Start the service only after the check passes. Route requests only after `/ready` reports ready. If provider access fails later, signing fails closed and readiness reports the signer unavailable. Readiness recovers after a successful provider self-test. ## Troubleshooting @@ -317,5 +286,4 @@ unavailable. Readiness recovers after a successful provider self-test. ## Next - [Build and deploy an Evidence Gateway project](../build-and-deploy-evidence-project/) -- [Issue Evidence Gateway access tokens with Registry Mint](../issue-evidence-access-tokens-with-registry-mint/) - [Rotate Evidence Gateway signing keys](../rotate-evidence-signing-keys/) diff --git a/docs/site/src/content/docs/tutorials/query-a-spatial-registry-from-qgis.mdx b/docs/site/src/content/docs/tutorials/query-a-spatial-registry-from-qgis.mdx deleted file mode 100644 index e5b8993365..0000000000 --- a/docs/site/src/content/docs/tutorials/query-a-spatial-registry-from-qgis.mdx +++ /dev/null @@ -1,347 +0,0 @@ ---- -title: Query a spatial registry from QGIS -description: Start a synthetic PostGIS registry, connect QGIS with an installation credential, and refresh the map after an authenticated write. -status: current -owner: registry-docs -source_repos: - - registry-stack -last_reviewed: "2026-09-08" -doc_type: tutorial -persona: - - data publisher - - operator -locale: en -standards_referenced: - - ogc-api-features - - geojson ---- - -import QuickstartMeta from '../../../components/QuickstartMeta.astro'; - -If you are evaluating Base Registry Engine's spatial read path, this tutorial starts a synthetic -service-site registry on PostGIS and connects QGIS as a read-only installation client. PostGIS is the -PostgreSQL extension that adds geometry types and spatial predicates. Base Registry Engine requires it -only when a profile that lists records also declares a bounding box query, and the `--spatial` launcher -flag supplies it by starting a PostGIS image in place of the plain PostgreSQL one. You will -display authorized Point records, pan within a bounded map area, import one record through the -normal API, and see the new Point after QGIS renews its Registry Mint credential. - - - -## Before you start - -[Create and query your first registry](../first-breg/) installs `breg`, `bregctl`, and `mint`. -The spatial launcher and its fixtures live in the repository, so clone it at the version you installed: - -```sh -installed="$(bregctl --version | awk '{print $2}')" -git clone --depth 1 --branch "v$installed" https://github.com/registrystack/registry-stack.git breg-tutorial -cd breg-tutorial -``` - -The project is pre-1.0, so the default branch may carry changes the installed binaries do not -implement; pinning the checkout keeps the launcher and the binaries on one contract. - -Confirm the binaries answer from that checkout: - -```sh -bregctl --version -mint --version -``` - -Run every shell command from the root of that clone. Keep two terminals open: the first runs Base -Registry Engine and Registry Mint, while the second performs the refresh write. What this tutorial -adds to that setup is QGIS, which needs a desktop session. - -:::caution[Use synthetic data only] -The quickstart creates local keys, tokens, and a disposable database under -`products/breg/quickstart/.run/`. A later quickstart run replaces that directory. -Do not put production data or credentials in this exercise. -::: - -{/* Evidence: products/breg/quickstart/run.sh, spatial_postgres_image; - crates/registry-breg/src/generated_ddl.rs, requires_postgis(). */} - -## Start the spatial registry - -In the first terminal, start the spatial quickstart: - -```sh -products/breg/quickstart/run.sh --installed --spatial -``` - -The `--installed` flag makes the launcher use the `breg`, `bregctl`, and `mint` you installed -rather than building them from the checkout. The launcher first prints -`== Using installed breg, bregctl, and mint from PATH` with the three paths it resolved. The first -run also downloads the pinned PostGIS image. Wait for this banner; the ports, the operator token -suffix, and the absolute paths vary: - -```text -Base Registry Engine spatial service-site quickstart is ready. - Base Registry Engine: http://127.0.0.1: - Registry Mint: http://127.0.0.1: - Project: /products/breg/quickstart/.run/project - Runtime config: /products/breg/quickstart/.run/runtime.yaml - QGIS OAPIF URL: http://127.0.0.1:/v1/gis - Collection: service-site.installation-map-reader - QGIS client id: qgis-installation-central - QGIS secret: /products/breg/quickstart/.run/secrets/qgis-client-secret - Operator token: /products/breg/quickstart/.run/secrets/operator-token- - Map token: /products/breg/quickstart/.run/secrets/map-token - Logs: /products/breg/quickstart/.run/logs -``` - -This tutorial uses the QGIS OAPIF URL, the collection, the client id, and the client secret. The -operator and map tokens are the launcher's own short-lived credentials; the import step mints a -fresh one instead of reusing them. - -Leave the first terminal running. The launcher has compiled, tested, packaged, and applied the -synthetic service-site project. It has also seeded Point records and checked native JSON, -GeoJSON, and the QGIS-compatible collection route. QGIS calls this provider OAPIF, short for -Open Geospatial Consortium (OGC) API Features. Base Registry Engine exposes only the compatibility -routes named in this tutorial and publishes an empty conformance list. - -{/* Evidence: products/breg/quickstart/run.sh; - products/breg/quickstart/support/quickstart.py, spatial_smoke() and QGIS_CLIENT_ID; - products/breg/scripts/test_quickstart.py. */} - -## Set a bounded QGIS map view - -Start an empty QGIS project. Select the project coordinate reference system control in the -bottom-right corner and choose **OGC:CRS84 (WGS 84 (CRS84))**. - -In the QGIS Locator, enter `go 100.55,13.75` and select the result in the current project -coordinate reference system. Set the status-bar **Scale** to `1:5000`. Switch the coordinate -display to **Extents** and confirm that the longitude span is less than `0.24` degrees and the -latitude span is less than `0.19` degrees. - -The `installation-map-reader` profile caps a bounding box at `0.25` longitude degrees by `0.20` -latitude degrees, and a world view exceeds it, so Base Registry Engine refuses the query with -`query.invalid`. The smaller view leaves room for QGIS decimal serialization and for canvas -aspect-ratio changes. The cap limits how much of the map one query may ask for, not which records -you may see; that is the profile's row boundary, which you meet in the next steps. - -{/* Evidence: products/breg/acceptance/spatial-service-sites/registry.yaml; - crates/registry-breg/src/query.rs, parse_bbox(); - crates/registry-breg/src/api/mod.rs, first_page_spatial_query(); - crates/registry-breg/tests/postgres_spatial_read.rs, - real_postgres_spatial_bbox_reads_preserve_authority_and_geojson_audit(). */} - -## Store the installation credential in QGIS - -1. Open **Settings > Options > Authentication > Configurations**, then select - **Add a new authentication configuration**. Set a QGIS master password if prompted. -2. Name the configuration `Base Registry Engine spatial quickstart`. Set **Resource URL** to the - printed Base Registry Engine origin without `/v1/gis`. -3. Select **OAuth2 authentication**, **Grant flow: Client Credentials**, and - **Resource access token method: Header**. -4. Set **Token URL** to `http://127.0.0.1:/token`, using the printed Registry Mint - port. Set **Client ID** to `qgis-installation-central`. -5. Open the printed `qgis-client-secret` file in a local editor and copy its value into - **Client secret**. Leave **Scope** empty and leave **Persist between launches** off, then save - the configuration. - -The client secret stays in QGIS's authentication database. A saved QGIS project refers to the -configuration by its `authcfg` identifier; the secret and bearer token do not belong in the -layer URL or project file. - -:::caution[Keep the client secret local] -Do not paste the secret into shell history, a QGIS connection URL, logs, screenshots, or support -messages. The quickstart creates the secret file with owner-only permissions. -::: - -{/* Evidence: products/breg/quickstart/run.sh; - products/breg/quickstart/support/quickstart.py, - _mint_client_secret_client() and _require_owner_only_regular(); - products/breg/scripts/test_quickstart.py. */} - -## Add the protected Point layer - -1. Open **Layer > Data Source Manager > WFS / OGC API - Features**, then select **New**. -2. Name the connection `Base Registry Engine spatial quickstart`. Set **URL** to the printed - **QGIS OAPIF URL** and select the saved OAuth2 configuration under **Authentication**. -3. Keep GET and feature paging enabled. Set **Page size** to `25`, then save the connection. -4. Select the connection under **Server Connections** and select **Connect**. -5. Select `service-site.installation-map-reader`, enable - **Only request features overlapping the view extent**, and select **Add**. - -QGIS draws Point features near the map center. Press `F6` to open the attribute table. The -table includes `id`, `label`, `mapLabel`, `nativeId`, `serviceKind`, `siteCode`, and `zone`. - -Every `zone` value is `central`: the installation credential carries a `service_zones: central` -claim, and the profile's row boundary returns only records whose `zone` matches it. The bounding -box is a filter inside that boundary, not the boundary itself. Base Registry Engine applies the -same scope, purpose, row, and field rules to the QGIS-compatible route as to native reads; the -[API reference](../../reference/breg-api/#spatial-adapter) covers the route and its paging limits. - -{/* Evidence: crates/registry-breg/src/api/gis.rs, collections(), collection(), items(), - authorize_gis_collection(), parse_items_query() and query_options(); - products/breg/acceptance/spatial-service-sites/registry.yaml; - crates/registry-breg/tests/postgres_spatial_read.rs, - real_postgres_spatial_bbox_reads_preserve_authority_and_geojson_audit(). */} - -## Pan within the bounding box cap - -Close the attribute table and use the QGIS pan tool to move a short distance east or west. Keep -the status-bar extent within the longitude and latitude spans used in the earlier map setup. - -The visible Points move and reload. If QGIS reports `query.invalid`, reduce the scale value to -`1:2500`, return to `100.55,13.75`, and reload the layer with `F5`. - -The `/v1/gis` surface supplies the route shape QGIS needs, including collection discovery, -absolute paging links, and GeoJSON features. Its conformance list is empty because Base Registry -Engine does not claim full Open Geospatial Consortium API Features conformance. - -{/* Evidence: crates/registry-breg/src/api/gis.rs, landing(), conformance(), api(), items() - and absolute_href(); crates/registry-breg/tests/postgres_spatial_read.rs. */} - -## Import a Point through Base Registry Engine - -Keep QGIS open. In the second terminal, validate and import the supplied refresh record: - -```sh -spatial_run="$PWD/products/breg/quickstart/.run" -spatial_input="$PWD/products/breg/acceptance/spatial-service-sites/fixtures/qgis-refresh-service-site.jsonl" -bregctl data validate \ - --package "$spatial_run/build/package" \ - --entity service-site --profile service-site-admin --operation create \ - --input "$spatial_input" - -spatial_refresh_token="$spatial_run/secrets/refresh-token-$(openssl rand -hex 8)" -mint token \ - --url "$(cat "$spatial_run/mint-origin")/token" \ - --client-id generic-quickstart \ - --key "$spatial_run/keys/operator/signing-p256-private-jwk" | - python3 products/breg/quickstart/support/quickstart.py \ - store-token --out "$spatial_refresh_token" -bregctl data import \ - --package "$spatial_run/build/package" \ - --breg-url "$(cat "$spatial_run/breg-origin")" \ - --access-token-file "$spatial_refresh_token" \ - --entity service-site --profile service-site-admin --operation create \ - --input "$spatial_input" \ - --checkpoint "$spatial_run/qgis-refresh-checkpoint.json" -``` - -The two summaries look like this; the digests depend on the project: - -```text -Validated the input. 1 item in 1 chunk. - package revision sha256: - schema fingerprint sha256: - entity service-site - profile service-site-admin - operation create - input bytes 305 - items 1 - chunks 1 - maximum items 100 - maximum bytes 262144 -``` - -```text -Imported the input. 1 item committed. - package revision sha256: - schema fingerprint sha256: - entity service-site - profile service-site-admin - operation create - input bytes 305 - items 1 - completed chunks 1 - committed items 1 - complete true -``` - -Run the block as one unit: every token the spatial quickstart mints, including the operator token -this block requests, expires after 60 seconds. The checkpoint records each committed chunk, so an -interrupted import resumes without sending a completed chunk again. If you run the import a -second time with the same checkpoint, it sends nothing and prints the same summary, because the -counts come from the checkpoint. The operator token stays in an owner-only file and is never copied into QGIS. - -{/* Evidence: crates/registry-bregctl/src/lib.rs; - products/breg/quickstart/support/quickstart.py, store_token(); - products/breg/acceptance/spatial-service-sites/fixtures/qgis-refresh-service-site.jsonl. */} - -## Refresh and confirm credential renewal - -Return to QGIS, select the layer, and press `F5`. Open the attribute table with `F6`. Use -**Select features using an expression** with this expression: - -```text -"siteCode" = 'SVC-QGIS-REFRESH' -``` - -Select the matching feature and move the selection to the top. QGIS shows one record with these -values: - -```text -label QGIS refresh example -mapLabel SVC-QGIS-REFRESH -serviceKind clinic -siteCode SVC-QGIS-REFRESH -zone central -``` - -Wait at least 95 seconds, then pan within the bounded extent and press `F5` again. The protected -layer reloads without asking you to paste a token. Spatial quickstart access tokens last 60 -seconds; QGIS uses the stored client credential to request a replacement from Registry Mint. - -{/* Evidence: products/breg/quickstart/support/quickstart.py, - SPATIAL_TOKEN_LIFETIME_SECONDS and _mint_client_secret_client(); - products/breg/acceptance/spatial-service-sites/fixtures/qgis-refresh-service-site.jsonl. */} - -## Stop the local services - -Close the QGIS project. In the first terminal, press `Ctrl+C`. The launcher stops Base Registry Engine, -Registry Mint, and the disposable database container. The files under -`products/breg/quickstart/.run/` remain until the next quickstart replaces them. - -If you do not plan to repeat the tutorial, remove the QGIS server connection and OAuth2 -configuration so the disposable client secret does not remain in your QGIS authentication -database. - -{/* Evidence: products/breg/quickstart/run.sh, cleanup(). */} - -## What you built - -- A local Base Registry Engine package with synthetic CRS84 Point records and PostGIS-backed bbox reads -- A protected QGIS layer using an OAuth2 client credential stored outside the project file -- A row-bounded map layer whose queries stay inside the profile's bounding box cap, with page size 25 and flat authorized attributes -- An authenticated API write that appeared after QGIS Refresh -- A protected layer reload after the short-lived access token expired - -## Troubleshooting - -| Symptom | Cause | Fix | -| --- | --- | --- | -| The first run takes several minutes on Apple Silicon | The pinned PostGIS image uses `linux/amd64`, so Docker Desktop may run it under emulation | Leave the launcher running; later runs reuse the downloaded image | -| The launcher exits before the banner and names `breg`, `bregctl`, or `mint` as required in `--installed` mode | The installer's directory is not on your `PATH`, or you skipped one of the installers in [Create and query your first registry](../first-breg/) | Add `~/.local/bin` to `PATH` in this terminal, rerun the installer you skipped, then start the launcher again | -| QGIS reports `query.invalid` while adding or panning the layer | The visible extent exceeds the profile's bounding box cap, often after QGIS adjusts the canvas aspect ratio | Return to `100.55,13.75`, set scale to `1:2500`, confirm the displayed spans are less than `0.24` and `0.19`, then press `F5` | -| QGIS asks for credentials or reports an OAuth2 error | The token URL, client ID, secret, or authentication selection does not match the current quickstart | Confirm the printed ports, use client ID `qgis-installation-central`, copy the current owner-only secret, leave Scope empty, and keep the first terminal running | -| The connection opens but no Point appears | The project starts at a world extent or outside the synthetic fixture area | Set project CRS to `OGC:CRS84`, locate `100.55,13.75`, reduce the scale, and reload | -| Refresh does not add `SVC-QGIS-REFRESH` | The import did not complete, QGIS still has an attribute filter, or the layer cache has not reloaded | Confirm the import reported `complete` as `true`, clear table filters, select the layer, and press `F5` | - -## Next - -- [Create and query your first registry](../first-breg/) covers Base Registry Engine - records, updates, refusals, and modules without the GIS client. -- [Author a registry project](../../configure/breg/) explains how to replace the - supplied fixture with your own entities, access profiles, and spatial grants. -- [Base Registry Engine API reference](../../reference/breg-api/#spatial-adapter) describes - the `/v1/gis` routes, the bounding box rules, and the GeoJSON encoding. -- [Deploy a registry](../../operate/breg/) covers running Base Registry Engine - against your own PostgreSQL with PostGIS. -- [QGIS authentication](https://docs.qgis.org/4.2/en/docs/user_manual/auth_system/auth_overview.html) - describes the client credential store used by this tutorial. diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index df23c4c976..f65e5c45ea 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -703,7 +703,7 @@ Return to the source-mock terminal and press `Ctrl+C` to stop it. response at the recorded decision time - [Control who can request Evidence](../control-who-can-request-evidence/), for policies and revocation -- [Request an access token from your own code](../../configure/request-an-access-token/), for the +- [Configure Evidence Gateway](../../configure/evidence/), for the token exchange without the client library - [Read the Registry Stack client API reference](../../reference/client-api/#evidence-gateway), for every client operation, token configuration, and error kind diff --git a/docs/site/src/content/docs/tutorials/review-registry-changes.mdx b/docs/site/src/content/docs/tutorials/review-registry-changes.mdx index cf964b05a5..d72f4755be 100644 --- a/docs/site/src/content/docs/tutorials/review-registry-changes.mdx +++ b/docs/site/src/content/docs/tutorials/review-registry-changes.mdx @@ -435,4 +435,4 @@ deployment are separate steps. `operator_erase`. - Open `products/breg/CHANGE_REQUEST_EXAMPLES.md` in your clone for the household example and stale-proposal recovery. -- [Configure Registry Mint](../../configure/mint/) when connecting callers to your own deployment. +- Configure your OpenID Connect provider when connecting callers to your own deployment. diff --git a/docs/site/src/data/projects.yaml b/docs/site/src/data/projects.yaml index d5f97a01d8..bb74c11574 100644 --- a/docs/site/src/data/projects.yaml +++ b/docs/site/src/data/projects.yaml @@ -89,30 +89,13 @@ - label: Operator contract url: https://github.com/registrystack/registry-stack/blob/HEAD/products/evidence/OPERATOR-CONTRACT.md rename_status: Developed in the Registry Stack monorepo as one `registry-evidence` crate beside the portable `registry-evidence-verifier` verification library; no separate repository. -- id: registry-mint - name: Registry Mint - repo_path: ../registry-stack - target_repo_path: ../registry-stack - role: Supporting service rather than a product line. Issues short-lived, audience-bound access tokens to registered machine clients, so a resource server such as Evidence Gateway can require signed expiring tokens without the deployment first standing up a general purpose identity provider. - owns: - - The client registry that binds a client id to its registered authentication material and to the authority Mint asserts for it. - - The `client_credentials` grant with `private_key_jwt` as the default client authentication, plus explicitly selected client-secret compatibility for standard authorization clients. - - Token signing, lifetime, and audience binding, and the `mint` binary. - does_not_own: - - End-user authentication, identity proofing, or consent. - - Caller-declared authority. Authority is written from the client registry, never from the assertion payload. - - Evidence Gateway requirement definitions, authorization grants, or disclosure decisions. - source_docs: - - label: README - url: https://github.com/registrystack/registry-stack/blob/HEAD/crates/registry-mint/README.md - rename_status: Developed in the Registry Stack monorepo as one `registry-mint` crate; no separate repository. - id: solmara-lab name: Solmara Lab repo_path: ../solmara-lab target_repo_path: ../solmara-lab - role: Standalone Registry Stack adopter demo for the fictional Republic of Solmara. Runs a whole country's worth of registry services over generated synthetic data, with six authority-owned Evidence Gateway cells that have their own providers, issuers, signing keys, JWKS, and audit sinks, five purpose-limited Relay projects, one lab Mint, a programme portal, and a public Visitor's Center. Some requirements read checkpointed immutable SQLite extracts and others read named Relay lookups, and the programme application composes the signed answers so that no authority decides for another. + role: Standalone Registry Stack adopter demo for the fictional Republic of Solmara. Runs a whole country's worth of registry services over generated synthetic data, with six authority-owned Evidence Gateway cells that have their own providers, issuers, signing keys, JWKS, and audit sinks, five purpose-limited Relay projects, one lab identity provider, a programme portal, and a public Visitor's Center. Some requirements read checkpointed immutable SQLite extracts and others read named Relay lookups, and the programme application composes the signed answers so that no authority decides for another. owns: - - End-to-end local and hosted demo topologies for the authority Evidence Gateway cells, their Relay projects, Mint, static metadata, scenario services, the programme portal, and the Visitor's Center. + - End-to-end local and hosted demo topologies for the authority Evidence Gateway cells, their Relay projects, identity provider, static metadata, scenario services, the programme portal, and the Visitor's Center. - Deterministic synthetic data, authority-owned demo configuration, purpose catalogues, and fictional country stories. - The demo publisher lifecycle that mutates a Relay-backed source live and republishes a named immutable extract that only a rebound cell serves. - Demo orchestration, smoke checks, browser journeys, hosted deployment wrappers, and release-pin checks. From b1afce734ae4284f70d9ef1c450d56c0cf87aa89 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:52:13 +0700 Subject: [PATCH 029/120] fix(casework): keep optional context types compatible with Python 3.10 Signed-off-by: Jeremi Joslin --- .../python/registry_casework_client/__init__.pyi | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index bd63a23fe1..3b14b8b9fc 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -1,4 +1,4 @@ -from typing import Generic, Literal, NotRequired, TypeAlias, TypedDict, TypeVar +from typing import Generic, Literal, TypeAlias, TypedDict, TypeVar JsonScalar: TypeAlias = str | int | float | bool | None JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] @@ -38,7 +38,9 @@ class EvidenceRequesterContext(TypedDict): class TaskApprovalRequest(TypedDict): templateId: str templateVersion: str -class TaskTemplatePreview(TypedDict): +class _OptionalEvidenceContext(TypedDict, total=False): + evidenceContext: EvidenceRequesterContext +class TaskTemplatePreview(_OptionalEvidenceContext): id: str version: str label: str @@ -46,7 +48,6 @@ class TaskTemplatePreview(TypedDict): client: str resource: str scopes: list[str] - evidenceContext: NotRequired[EvidenceRequesterContext] purpose: str bounds: TaskGrantBounds subjects: dict[str, str | int | bool] @@ -54,7 +55,7 @@ class TaskTemplatePreview(TypedDict): class TaskTemplatePreviews(TypedDict): itemRevision: int templates: list[TaskTemplatePreview] -class TaskGrantView(TypedDict): +class TaskGrantView(_OptionalEvidenceContext): id: str templateId: str templateVersion: str @@ -62,7 +63,6 @@ class TaskGrantView(TypedDict): client: str resource: str scopes: list[str] - evidenceContext: NotRequired[EvidenceRequesterContext] purpose: str bounds: TaskGrantBounds expiresAt: int From d1142c6967a4abf0a00b28efa517ff71f560a5f5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:56:21 +0700 Subject: [PATCH 030/120] docs: introduce BREG abbreviation Signed-off-by: Jeremi Joslin --- .../site/src/content/docs/configure/request-an-access-token.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/site/src/content/docs/configure/request-an-access-token.mdx b/docs/site/src/content/docs/configure/request-an-access-token.mdx index 280d3f76ac..0e79d3b8e3 100644 --- a/docs/site/src/content/docs/configure/request-an-access-token.mdx +++ b/docs/site/src/content/docs/configure/request-an-access-token.mdx @@ -12,7 +12,7 @@ standards_referenced: [] --- Use a private key JWT client assertion when an application needs its own short-lived access token -for Evidence Gateway, Base Registry Engine, or Registry Relay. The deployment's OAuth issuer owns +for Evidence Gateway, Base Registry Engine (BReg), or Registry Relay. The deployment's OAuth issuer owns client registration and token issuance. The resource server independently verifies the resulting token against its configured issuer, audience, algorithm, token type, and authority claims. From 30308f44c0adff881d3016a34c7ef6f2a4428f7c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:56:45 +0700 Subject: [PATCH 031/120] test(breg): use stock issuer in portability proof Signed-off-by: Jeremi Joslin --- .../registry-breg/tests/issuer_portability.rs | 24 +-- .../tests/postgres_change_requests.rs | 33 ++-- crates/registry-bregctl/src/dev/examples.rs | 6 +- .../src/init_from_model/render.rs | 4 +- products/breg/ACCEPTANCE-JOURNEYS.md | 2 +- .../acceptance/issuer-portability/README.md | 22 +-- .../issuer-portability/registry.yaml | 11 +- .../issuer-portability/tests/journeys.yaml | 15 ++ .../contracts/acceptance-scenario-matrix.yaml | 8 +- .../breg/scripts/test-issuer-portability.py | 158 ++++++++---------- 10 files changed, 146 insertions(+), 137 deletions(-) create mode 100644 products/breg/acceptance/issuer-portability/tests/journeys.yaml diff --git a/crates/registry-breg/tests/issuer_portability.rs b/crates/registry-breg/tests/issuer_portability.rs index 620fbe00cf..6faee5fb08 100644 --- a/crates/registry-breg/tests/issuer_portability.rs +++ b/crates/registry-breg/tests/issuer_portability.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -//! Opt-in proof using tokens issued by actual local Mint and Keycloak services. +//! Opt-in proof using tokens issued by the stock local issuer and Keycloak. //! Run products/breg/scripts/test-issuer-portability.py. The recording backend //! proves authorization before record I/O; PostgreSQL enforcement has its own gate. @@ -28,7 +28,6 @@ use serde_json::json; use tower::ServiceExt as _; use zeroize::Zeroizing; -const AUDIENCE: &str = "urn:breg:issuer-portability"; const PRINCIPAL: &str = "urn:institution:service-clerk"; const HUMAN_PRINCIPAL: &str = "urn:institution:human-clerk"; const PURPOSE: &str = "registry-administration"; @@ -43,7 +42,8 @@ struct Issuer { #[derive(Deserialize)] struct Journey { - mint: Issuer, + audience: String, + stock: Issuer, keycloak: Issuer, } @@ -141,7 +141,7 @@ async fn request(app: &axum::Router, token: &str, method: &str) -> StatusCode { #[tokio::test] #[ignore = "requires disposable issuers; run products/breg/scripts/test-issuer-portability.py"] -async fn mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer() { +async fn stock_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer() { let root = std::env::var_os("BREG_ISSUER_JOURNEY_DIR").expect("runner material directory"); let root = Path::new(&root); let journey: Journey = @@ -155,10 +155,10 @@ async fn mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer compile_project(&project, &[], CompileProfile::Authoring) .expect("portable project compiles"), ); - let mint = authenticator(®istry, root, &journey.mint, AUDIENCE); - let keycloak = authenticator(®istry, root, &journey.keycloak, AUDIENCE); + let stock = authenticator(®istry, root, &journey.stock, &journey.audience); + let keycloak = authenticator(®istry, root, &journey.keycloak, &journey.audience); let tokens: Vec> = [ - "mint.token", + "stock.token", "service.token", "human.token", "no-scope.token", @@ -167,9 +167,9 @@ async fn mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer .map(|name| Zeroizing::new(std::fs::read_to_string(root.join(name)).expect("issued token"))) .collect(); for (index, issuer, scopes) in [ - (0, &mint, vec!["registry.read"]), - (1, &keycloak, vec!["registry.read"]), - (2, &keycloak, vec!["openid", "registry.read"]), + (0, &stock, vec!["registry:read"]), + (1, &keycloak, vec!["registry:read"]), + (2, &keycloak, vec!["openid", "registry:read"]), ] { println!("Checking issuer token case {index}"); let claims = issuer @@ -241,7 +241,7 @@ async fn mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer ); } assert!( - mint.authenticate(&tokens[1]).await.is_err(), + stock.authenticate(&tokens[1]).await.is_err(), "new issuer needs explicit trust" ); assert!( @@ -258,6 +258,6 @@ async fn mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer ); println!( "{}", - json!({"mintService": "passed", "keycloakService": "passed", "keycloakAuthorizationCodePkce": "passed", "issuerCutover": "passed", "databaseEnforcement": "separate-gate"}) + json!({"stockIssuer": "passed", "keycloakService": "passed", "keycloakAuthorizationCodePkce": "passed", "issuerCutover": "passed", "databaseEnforcement": "separate-gate"}) ); } diff --git a/crates/registry-breg/tests/postgres_change_requests.rs b/crates/registry-breg/tests/postgres_change_requests.rs index a4fdf05949..236dcb1b58 100644 --- a/crates/registry-breg/tests/postgres_change_requests.rs +++ b/crates/registry-breg/tests/postgres_change_requests.rs @@ -4347,13 +4347,13 @@ async fn real_postgres_http_change_request_apply_cancels_when_startup_timeout_dr #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires actual local issuers and PostgreSQL; run test-issuer-portability.py --with-postgres"] -async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { +async fn stock_to_keycloak_continues_persisted_review_with_stable_principal() { let database = TestDatabase::create(8).await; let mut project = two_stage_project(); for profile in &mut project.access_profiles { if !profile.required_purposes.is_empty() { profile.required_purposes = BTreeSet::from(["registry-administration".to_owned()]); - profile.required_scopes = BTreeSet::from(["registry.read".to_owned()]); + profile.required_scopes = BTreeSet::from(["registry:read".to_owned()]); } } let registry = Arc::new( @@ -4382,7 +4382,7 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { .expect("JWKS JSON"); let config = registry_platform_oidc::TokenVerifierConfig::access_token_profile( issuer["issuer"].as_str().expect("issuer"), - vec!["urn:breg:issuer-portability".to_owned()], + vec![manifest["audience"].as_str().expect("audience").to_owned()], vec![serde_json::from_value(issuer["algorithm"].clone()).expect("algorithm")], vec![issuer["token_type"] .as_str() @@ -4406,10 +4406,11 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { .expect("explicit issuer authority contract"), ) }; - let mint = authenticator(&manifest["mint"]); + let stock = authenticator(&manifest["stock"]); let keycloak = authenticator(&manifest["keycloak"]); - let mint_token = - Zeroizing::new(std::fs::read_to_string(root.join("mint.token")).expect("Mint token")); + let stock_token = Zeroizing::new( + std::fs::read_to_string(root.join("stock.token")).expect("stock issuer token"), + ); let service_token = Zeroizing::new( std::fs::read_to_string(root.join("service.token")).expect("Keycloak service token"), ); @@ -4417,7 +4418,7 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { std::fs::read_to_string(root.join("human.token")).expect("Keycloak human token"), ); drop(app); - let mint_app = registry_breg::api::authenticated_router( + let stock_app = registry_breg::api::authenticated_router( change_request_service( &database, registry.clone(), @@ -4425,16 +4426,16 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { "two-stage-change-request", None, ), - mint, + stock, ); let first = bearer_request( - &mint_app, + &stock_app, Method::GET, &format!( "/v1/records/correction-requests/{}?accessProfile=reviewer", request.id ), - &mint_token, + &stock_token, &[], Vec::new(), ) @@ -4442,19 +4443,19 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { assert_eq!(first.status, StatusCode::OK); let first_approve = action(&first.body, "approve_request", Some("review")); let first_result = bearer_action( - &mint_app, + &stock_app, &first_approve, "issuer-first-approval", - &mint_token, + &stock_token, &digest, ) .await; assert_eq!(first_result.status, StatusCode::OK); let first_page = bearer_request( - &mint_app, + &stock_app, Method::GET, "/v1/records/sites?accessProfile=steward&$top=1", - &mint_token, + &stock_token, &[], Vec::new(), ) @@ -4464,7 +4465,7 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { .as_str() .expect("two sites produce a continuation") .to_owned(); - drop(mint_app); + drop(stock_app); let keycloak_app = registry_breg::api::authenticated_router( change_request_service( &database, @@ -4521,7 +4522,7 @@ async fn mint_to_keycloak_continues_persisted_review_with_stable_principal() { &keycloak_app, Method::GET, &uri, - &mint_token, + &stock_token, &[], Vec::new() ) diff --git a/crates/registry-bregctl/src/dev/examples.rs b/crates/registry-bregctl/src/dev/examples.rs index ebb83db781..99513d6615 100644 --- a/crates/registry-bregctl/src/dev/examples.rs +++ b/crates/registry-bregctl/src/dev/examples.rs @@ -1607,7 +1607,7 @@ mod tests { }) } #[test] - #[ignore = "requires source-built bregctl/breg/mint and Docker; creates one disposable owned dev database"] + #[ignore = "requires source-built bregctl/breg and Docker; creates one disposable owned dev database"] fn native_create_and_apply_recover_after_process_exit_without_duplicate_revisions() { let temp = tempfile::Builder::new() .prefix("breg-example-recovery-") @@ -1640,11 +1640,9 @@ mod tests { owned.succeed(&[ "--breg-bin", binaries.join("breg").to_str().unwrap(), - "--mint-bin", - binaries.join("mint").to_str().unwrap(), "--breg-port", &ports[0], - "--mint-port", + "--issuer-port", &ports[1], "--database-port", &ports[2], diff --git a/crates/registry-bregctl/src/init_from_model/render.rs b/crates/registry-bregctl/src/init_from_model/render.rs index 1012a4600b..87f5f6467a 100644 --- a/crates/registry-bregctl/src/init_from_model/render.rs +++ b/crates/registry-bregctl/src/init_from_model/render.rs @@ -604,8 +604,8 @@ fn dev_clients(plan: &Plan) -> String { let mut yaml = Yaml::default(); yaml.comment( 0, - "Local callers for `bregctl dev`. Registry Mint, the local token issuer that `dev` \ - starts beside the registry, registers each client below and issues it short-lived \ + "Local callers for `bregctl dev`. The stock local identity provider that `dev` \ + starts beside the registry registers each client below and issues it short-lived \ tokens carrying these claims. One client binds each access profile that \ tests/journeys.yaml uses, with the claims those journeys expect, so a first start \ runs the journeys and serves the package without another file. `dev` generates a \ diff --git a/products/breg/ACCEPTANCE-JOURNEYS.md b/products/breg/ACCEPTANCE-JOURNEYS.md index c4b3810a02..b11bce7d75 100644 --- a/products/breg/ACCEPTANCE-JOURNEYS.md +++ b/products/breg/ACCEPTANCE-JOURNEYS.md @@ -76,7 +76,7 @@ one governed surface, and each is executed by the test or script named beside it | `spatial-service-sites` | Bounding-box read permissions over spatial rows | `postgres_fixture_journeys.rs` in the postgres lane, and `quickstart/run.sh --spatial --smoke`, whose contract `test_quickstart.py` holds under `scripts/check-contracts.sh` | | `registry-record-conformance` | The HTTP record contract across every configured record profile | `postgres_record_profile_conformance.rs` in the postgres lane | | `household-history` | Historical households loaded and queried across effective periods | `scripts/test-historical-workflow.sh` only, which no continuous integration job selects | -| `issuer-portability` | An authority cutover from Registry Mint to a second issuer, with the old issuer rejected | the ignored `issuer_portability.rs` test, driven by `scripts/test-issuer-portability.py` only, which no continuous integration job selects | +| `issuer-portability` | An authority cutover from the pinned stock issuer to a second issuer, with the old issuer rejected | the ignored `issuer_portability.rs` test, driven by `scripts/test-issuer-portability.py` only, which no continuous integration job selects | The last two rows are run by local scripts alone. They are maintained acceptance projects with executable proof, but nothing in diff --git a/products/breg/acceptance/issuer-portability/README.md b/products/breg/acceptance/issuer-portability/README.md index a7966ce1d8..5742471915 100644 --- a/products/breg/acceptance/issuer-portability/README.md +++ b/products/breg/acceptance/issuer-portability/README.md @@ -1,17 +1,17 @@ # Identity-provider portability journey -This opt-in local journey obtains actual tokens from Registry Mint and a +This opt-in local journey obtains actual tokens from the pinned stock ThunderID issuer and a digest-pinned Keycloak 26.7.3 container, then passes them through BReg's real authenticator and HTTP router. It uses generated credentials and synthetic identities only. Docker, Cargo, and `uv` are required. ```sh -cargo build --locked -p registry-mint --bin mint +cargo build --locked -p registry-bregctl -p registry-breg --bin bregctl --bin breg uv run products/breg/scripts/test-issuer-portability.py ``` -`CARGO_TARGET_DIR` is respected; `--mint /absolute/path/to/mint` selects an -already-built matching binary. The runner owns a unique disposable container, +`CARGO_TARGET_DIR` is respected; `--bregctl` and `--breg` select already-built matching +binaries. The runner owns a unique disposable container, dynamic loopback ports, and an owner-only temporary directory. It cleans up services and credentials when finished. It does not contact an existing realm. @@ -23,19 +23,19 @@ for the product's PostgreSQL tests, and opt in: uv run products/breg/scripts/test-issuer-portability.py --with-postgres ``` -This additional case records the first approval using a real Mint token, +This additional case records the first approval using a real stock-issuer token, reconstructs BReg with Keycloak trust against the same database, refuses the -old Mint token and the same service principal's attempt to approve another +old stock-issuer token and the same service principal's attempt to approve another stage, then accepts the independent human's Keycloak token. The runner never creates or resets the supplied database. Builds finish before token issuance. It also continues the original principal's pagination cursor and replays a committed approval receipt after cutover. Another principal cannot reuse the cursor. The default registry project requires the `registry_principal` direct claim, -`registry.read` permission, `registry-administration` purpose, and `districts` +`registry:read` permission, `registry-administration` purpose, and `districts` assignment. It authorizes GET only, for assigned districts. The proof covers: -- Mint client credentials and Keycloak service-account client credentials. +- Stock ThunderID and Keycloak service-account client credentials. - Keycloak's interactive authorization endpoint, real login form and session, authorization-code redirect, state validation, and PKCE code exchange. Direct password grants are disabled. @@ -47,9 +47,9 @@ assignment. It authorizes GET only, for assigned districts. The proof covers: - New issuer acceptance after explicit trust replacement, old issuer refusal, and wrong-resource refusal. -Mint issues its registered scopes and configured audience. It does not offer -request-time scope downscoping or resource selection. Keycloak uses an optional -`registry.read` client scope and a BReg audience mapper. Both therefore issue +The stock issuer receives the exact resource, scopes, and authority from the retained development +client registration. Keycloak uses an optional +`registry:read` client scope and a BReg audience mapper. Both therefore issue the same BReg permission and resource contract through different provisioning mechanisms. No production authentication dependency on either issuer is added. diff --git a/products/breg/acceptance/issuer-portability/registry.yaml b/products/breg/acceptance/issuer-portability/registry.yaml index ad88fd18ba..b1280d92ec 100644 --- a/products/breg/acceptance/issuer-portability/registry.yaml +++ b/products/breg/acceptance/issuer-portability/registry.yaml @@ -5,6 +5,11 @@ registry: version: 0.1.0 defaultLanguage: en canonicalBaseIri: https://registry.example.test +package: + environment: local + instanceId: issuer-portability-acceptance + sequence: 1 + sourceRevision: issuer-portability-acceptance-0.1.0 entities: - id: record primaryDataset: records @@ -19,11 +24,11 @@ accessProfiles: - id: clerk default: true principalClaim: registry_principal - requiredScopes: [registry.read] + requiredScopes: [registry:read] requiredPurposes: [registry-administration] permissions: - entity: record - operations: [get] + operations: [get, list] readableFields: [label] rowBoundaries: - - {field: district, claim: districts, operator: in} + - {field: district, claim: districts, operator: equals} diff --git a/products/breg/acceptance/issuer-portability/tests/journeys.yaml b/products/breg/acceptance/issuer-portability/tests/journeys.yaml new file mode 100644 index 0000000000..dcdbe942c1 --- /dev/null +++ b/products/breg/acceptance/issuer-portability/tests/journeys.yaml @@ -0,0 +1,15 @@ +apiVersion: registry.registrystack.org/breg-journeys/v1 +journeys: + - id: issuer-portability-package + steps: + - id: list-empty-registry + entity: record + accessProfile: clerk + claims: + principal: urn:institution:service-clerk + request: + operation: list + expect: + outcome: refusal + status: 404 + problemCode: resource.not_found diff --git a/products/breg/contracts/acceptance-scenario-matrix.yaml b/products/breg/contracts/acceptance-scenario-matrix.yaml index 87322e960b..4da1815e1b 100644 --- a/products/breg/contracts/acceptance-scenario-matrix.yaml +++ b/products/breg/contracts/acceptance-scenario-matrix.yaml @@ -43,12 +43,12 @@ scenarios: evidence: [{path: crates/registry-breg/tests/postgres_pilot_acceptance.rs, name: real_postgres_five_domain_pilot_is_configured_production_closed_and_source_neutral}, {path: products/breg/scripts/check-source-neutrality.sh, name: check-source-neutrality.sh}, {path: products/breg/scripts/test_check_source_neutrality.py, name: test_public_kernel_contract_canary_is_rejected}] - id: BREG-J08 state: enforced - doneWhen: "Hidden processing, unauthorized writes, wrong purpose, stale ETags, changed idempotency, and malformed tokens refuse without leakage." - evidence: [{path: crates/registry-breg/tests/compiler_contract.rs, name: anonymous_public_profile_cannot_filter_a_non_public_field}, {path: crates/registry-breg/tests/compiler_contract.rs, name: anonymous_public_surface_rejects_every_non_public_constraint_field}, {path: crates/registry-breg/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}, {path: crates/registry-breg/tests/http_auth.rs, name: issuer_audience_algorithm_token_type_and_signature_are_all_verified}, {path: crates/registry-breg/tests/postgres_record_profile_conformance.rs, name: real_postgres_registry_record_profile_matches_the_cross_product_semantic_gold}] + doneWhen: "Hidden processing, unauthorized writes, wrong purpose, stale ETags, changed idempotency, malformed tokens, cross-grant reads, and task-grant fallback to standing authority refuse without leakage." + evidence: [{path: crates/registry-breg/tests/compiler_contract.rs, name: anonymous_public_profile_cannot_filter_a_non_public_field}, {path: crates/registry-breg/tests/compiler_contract.rs, name: anonymous_public_surface_rejects_every_non_public_constraint_field}, {path: crates/registry-breg/tests/postgres_mutation.rs, name: real_postgres_http_mutations_are_guarded_and_exactly_replayable}, {path: crates/registry-breg/tests/http_auth.rs, name: issuer_audience_algorithm_token_type_and_signature_are_all_verified}, {path: crates/registry-breg/tests/http_auth.rs, name: task_grant_is_exactly_bound_and_cannot_fall_back_to_standing_authority}, {path: crates/registry-breg/tests/postgres_record_profile_conformance.rs, name: real_postgres_registry_record_profile_matches_the_cross_product_semantic_gold}] - id: BREG-J09 state: enforced - doneWhen: "Package, profile, purpose, row boundary, projection, and query changes invalidate replay after fresh authorization." - evidence: [{path: crates/registry-breg/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-breg/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}] + doneWhen: "Package, profile, purpose, row boundary, projection, query, and delegated task status changes invalidate replay after fresh authorization." + evidence: [{path: crates/registry-breg/tests/postgres_mutation.rs, name: real_postgres_mutation_is_audited_atomic_typed_and_exactly_replayable}, {path: crates/registry-breg/tests/postgres_read.rs, name: real_postgres_temporal_keyset_and_cursor_binding_edges_are_enforced}, {path: crates/registry-breg/tests/postgres_task_grants.rs, name: task_http_to_postgres_preserves_original_authority_and_completed_receipts}, {path: crates/registry-breg/tests/postgres_task_grants.rs, name: automatic_apply_rechecks_original_task_after_human_approval}] - id: BREG-J10 state: enforced doneWhen: "Fault injection proves atomic record, revision, audit, idempotency, and configured outbox state." diff --git a/products/breg/scripts/test-issuer-portability.py b/products/breg/scripts/test-issuer-portability.py index dd8d9ef57c..6a02b40da1 100644 --- a/products/breg/scripts/test-issuer-portability.py +++ b/products/breg/scripts/test-issuer-portability.py @@ -6,7 +6,7 @@ # SPDX-License-Identifier: Apache-2.0 """Exercise real disposable issuers through BREG's authenticated router. -Uses the maintained Mint key helper and an ordinary Cargo integration test. +Uses the maintained stock ThunderID development issuer and an ordinary Cargo integration test. No existing containers, databases, or identity-provider accounts are touched. Tokens, generated credentials, and private keys live only in a temporary directory. """ @@ -17,7 +17,6 @@ import base64 import hashlib import http.cookiejar -import importlib.util import json import os import secrets @@ -37,12 +36,11 @@ ROOT = Path(__file__).resolve().parents[3] KEYCLOAK = "quay.io/keycloak/keycloak:26.7.3@sha256:ff4257d0d64efbe99ed1ddfaf07765cc3c36dc7518bf8324d41961327f441c54" -AUDIENCE = "urn:breg:issuer-portability" PRINCIPAL = "urn:institution:service-clerk" HUMAN_PRINCIPAL = "urn:institution:human-clerk" PURPOSE = "registry-administration" -POSTGRES_TEST = "mint_to_keycloak_continues_persisted_review_with_stable_principal" -ROUTER_TEST = "mint_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer" +POSTGRES_TEST = "stock_to_keycloak_continues_persisted_review_with_stable_principal" +ROUTER_TEST = "stock_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issuer" def write(path: Path, value: object, *, container_readable: bool = False) -> None: @@ -100,7 +98,7 @@ def wait_ready(url: str, deadline_seconds: int = 120, process=None) -> None: deadline = time.monotonic() + deadline_seconds while time.monotonic() < deadline: if process is not None and process.poll() is not None: - raise RuntimeError("Mint exited before becoming ready; check its generated configuration") + raise RuntimeError("issuer exited before becoming ready; inspect its private diagnostics") try: with request(url) as response: if response.status == 200: @@ -119,29 +117,29 @@ def mapper(name: str, kind: str, config: dict) -> dict: "userinfo.token.claim": "false", **config}} -def realm(client_secret: str, password: str, callback: str) -> dict: +def realm(client_secret: str, password: str, callback: str, audience: str) -> dict: authority = [ mapper("stable principal", "oidc-usermodel-attribute-mapper", { "user.attribute": "registry_principal", "claim.name": "registry_principal", "jsonType.label": "String"}), mapper("district assignments", "oidc-usermodel-attribute-mapper", { "user.attribute": "districts", "claim.name": "districts", - "jsonType.label": "String", "multivalued": "true"}), + "jsonType.label": "String"}), mapper("tenant assignment", "oidc-hardcoded-claim-mapper", { "claim.name": "tenant_claim", "claim.value": "tenant-a", "jsonType.label": "String"}), mapper("purpose", "oidc-hardcoded-claim-mapper", { "claim.name": "purpose", "claim.value": PURPOSE, "jsonType.label": "String"}), - mapper("BREG resource", "oidc-audience-mapper", {"included.custom.audience": AUDIENCE}), + mapper("BREG resource", "oidc-audience-mapper", {"included.custom.audience": audience}), ] client = {"protocol": "openid-connect", "enabled": True, - "defaultClientScopes": [], "optionalClientScopes": ["registry.read"], + "defaultClientScopes": [], "optionalClientScopes": ["registry:read"], "protocolMappers": authority, "directAccessGrantsEnabled": False, "fullScopeAllowed": False} assignments = {"registry_principal": [PRINCIPAL], "districts": ["district-a"]} return { "realm": "breg-issuer-journey", "enabled": True, "sslRequired": "none", "accessTokenLifespan": 300, - "clientScopes": [{"name": "registry.read", "protocol": "openid-connect", + "clientScopes": [{"name": "registry:read", "protocol": "openid-connect", "attributes": {"include.in.token.scope": "true"}}], "clients": [ {**client, "clientId": "clerk-service", "secret": client_secret, @@ -200,7 +198,7 @@ def human_token(issuer: str, password: str, callback: str) -> str: state = secrets.token_urlsafe(24) authorization = issuer + "/protocol/openid-connect/auth?" + urllib.parse.urlencode({ "client_id": "clerk-browser", "redirect_uri": callback, "response_type": "code", - "scope": "openid registry.read", "state": state, + "scope": "openid registry:read", "state": state, "code_challenge": challenge, "code_challenge_method": "S256"}) browser = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar(LoopbackBrowserCookies(issuer))), NoRedirect()) @@ -236,63 +234,57 @@ def human_token(issuer: str, password: str, callback: str) -> str: return json.load(response)["access_token"] -def journey(root: Path, mint: str, docker: str, test_binaries: dict[str, str]) -> None: - mint_origin = f"http://127.0.0.1:{port()}" +def journey(root: Path, bregctl: str, breg: str, docker: str, + test_binaries: dict[str, str]) -> None: + project = root / "stock-project" + shutil.copytree(ROOT / "products/breg/acceptance/issuer-portability", project) + write(project / "dev-clients.yaml", """version: 1 +clients: + - id: clerk-service + accessProfiles: [clerk] + scopes: [registry:read] + claims: + registry_principal: urn:institution:service-clerk + purpose: registry-administration + districts: district-a + tenant_claim: tenant-a +""") + breg_port, stock_port, database_port = port(), port(), port() keycloak_port = port() keycloak_origin = f"http://127.0.0.1:{keycloak_port}" issuer = keycloak_origin + "/realms/breg-issuer-journey" callback = f"http://127.0.0.1:{port()}/callback" - key_helper = ROOT / "crates/registry-mint/demo/support/key_material.py" - spec = importlib.util.spec_from_file_location("mint_key_material", key_helper) - helper = importlib.util.module_from_spec(spec) - spec.loader.exec_module(helper) - private, public = helper.p256_jwk() - write(root / "keys/signing-p256-private-jwk", private) - write(root / "keys/audit-hmac-key", secrets.token_hex(32)) - public_path = f"public-keys/{public['kid']}.jwk.json" - write(root / public_path, public) - fingerprint = run([mint, "client-secret", "generate", "--out", str(root / "client-secret")]).stdout.decode().strip() - write(root / "clients/clerk.yaml", { - "clientId": "clerk-service", "principal": PRINCIPAL, - "authorization": {"scopes": ["registry.read"], "claims": { - "registry_principal": PRINCIPAL, "purpose": PURPOSE, "districts": ["district-a"], - "tenant_claim": "tenant-a"}}, - "clientAuthentication": {"method": "client-secret", "secretFingerprints": [fingerprint]}}) - write(root / "mint.json", { - "version": 1, "validationMode": "supervised-local-development", "issuer": mint_origin, - "listener": {"address": "127.0.0.1", "port": int(mint_origin.rsplit(":", 1)[1])}, - "signing": {"algorithm": "ES256", "activePublicJwkFile": public_path, - "publishedPublicJwkFiles": [], "revokedKeyIds": []}, - "signer": {"kind": "local-jwk", "privateKeyRef": "secret:file/signing-p256-private-jwk"}, - "secretProviders": {"file": {"root": str(root / "keys")}}, - "audit": {"path": "mint-audit.jsonl", "maximumFileBytes": 10485760, - "hashKeyRef": "secret:file/audit-hmac-key", "hashKeyVersion": 1}, - "accessTokens": {"audiences": [AUDIENCE], "lifetimeSeconds": 300}, - "clientAssertion": {"audience": mint_origin + "/token", "maximumLifetimeSeconds": 120, - "algorithms": ["ES256"]}, "clients": {"directory": "clients"}}) - client_secret, password = secrets.token_urlsafe(32), secrets.token_urlsafe(32) - write(root / "import/realm.json", realm(client_secret, password, callback), container_readable=True) - # Only the mounted import directory is traversable by the container user; - # its owner-only host ancestor keeps generated login credentials private. - (root / "import").chmod(0o755) + session_started = False name = "breg-issuer-journey-" + secrets.token_hex(6) - process = None + client_secret, password = secrets.token_urlsafe(32), secrets.token_urlsafe(32) try: - print("Starting disposable Mint and Keycloak issuers", flush=True) - process = subprocess.Popen([mint, "serve", "--config", str(root / "mint.json")], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + print("Starting the stock ThunderID issuer", flush=True) + run([bregctl, "--format", "json", "dev", "--breg-bin", breg, + "--docker-bin", docker, "--breg-port", str(breg_port), + "--issuer-port", str(stock_port), "--database-port", str(database_port), + str(project)]) + session_started = True + state = json.loads((project / ".breg/dev/state.json").read_text()) + stock_origin = f"http://127.0.0.1:{state['issuerPort']}" + audience = f"urn:breg:dev:{state['owner']}" + run([bregctl, "--format", "json", "dev", "token", "clerk-service", str(project)]) + header = (project / ".breg/dev/secrets/clerk-service.header").read_text().strip() + prefix = "Authorization: Bearer " + if not header.startswith(prefix): + raise RuntimeError("stock issuer did not produce the expected private header file") + write(root / "stock.token", header[len(prefix):]) + shutil.copyfile(project / ".breg/dev/secrets/issuer-jwks", root / "stock-jwks.json") + + write(root / "import/realm.json", realm(client_secret, password, callback, audience), + container_readable=True) + (root / "import").chmod(0o755) + print("Starting the disposable Keycloak issuer", flush=True) run([docker, "run", "--detach", "--name", name, "--publish", f"127.0.0.1:{keycloak_port}:8080", "--mount", f"type=bind,src={root / 'import'},dst=/opt/keycloak/data/import,readonly", KEYCLOAK, "start-dev", "--import-realm", "--hostname", keycloak_origin]) - wait_ready(mint_origin + "/ready", process=process) wait_ready(issuer + "/.well-known/openid-configuration") - print("Obtaining the registered Mint machine token", flush=True) - basic = base64.b64encode(("clerk-service:" + (root / "client-secret").read_text().strip()).encode()).decode() - with request(mint_origin + "/token", {"grant_type": "client_credentials"}, - {"Authorization": "Basic " + basic}) as response: - write(root / "mint.token", json.load(response)["access_token"]) - for name_suffix, scope in [("service", "registry.read"), ("no-scope", "")]: + for name_suffix, scope in [("service", "registry:read"), ("no-scope", "")]: print(f"Obtaining Keycloak {name_suffix} token", flush=True) with request(issuer + "/protocol/openid-connect/token", { "grant_type": "client_credentials", "client_id": "clerk-service", @@ -300,15 +292,14 @@ def journey(root: Path, mint: str, docker: str, test_binaries: dict[str, str]) - write(root / f"{name_suffix}.token", json.load(response)["access_token"]) print("Exercising human authorization-code login with PKCE", flush=True) write(root / "human.token", human_token(issuer, password, callback)) - with request(mint_origin + "/.well-known/openid-configuration") as response: - mint_metadata = json.load(response) - for provider, jwks_uri in [("mint", mint_metadata["jwks_uri"]), - ("keycloak", issuer + "/protocol/openid-connect/certs")]: - with request(jwks_uri) as response: - write(root / f"{provider}-jwks.json", json.load(response)) + with request(issuer + "/protocol/openid-connect/certs") as response: + write(root / "keycloak-jwks.json", json.load(response)) write(root / "journey.json", { - "mint": {"issuer": mint_origin, "algorithm": "ES256", "token_type": "at+jwt", "jwks_file": "mint-jwks.json"}, - "keycloak": {"issuer": issuer, "algorithm": "RS256", "token_type": "JWT", "jwks_file": "keycloak-jwks.json"}}) + "audience": audience, + "stock": {"issuer": stock_origin, "algorithm": "RS256", "token_type": "at+jwt", + "jwks_file": "stock-jwks.json"}, + "keycloak": {"issuer": issuer, "algorithm": "RS256", "token_type": "JWT", + "jwks_file": "keycloak-jwks.json"}}) print("Verifying issued tokens, authority and issuer cutover in the real BREG router", flush=True) environment = dict(os.environ, BREG_ISSUER_JOURNEY_DIR=str(root)) subprocess.run([test_binaries["router"], ROUTER_TEST, "--ignored", "--exact", "--nocapture"], @@ -318,35 +309,34 @@ def journey(root: Path, mint: str, docker: str, test_binaries: dict[str, str]) - subprocess.run([test_binaries["postgres"], POSTGRES_TEST, "--ignored", "--exact", "--nocapture"], cwd=ROOT / "crates/registry-breg", env=environment, check=True) finally: - if process: - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - removed = subprocess.run([docker, "rm", "--force", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if removed.returncode: - print(f"Could not remove the disposable container {name}; inspect it with Docker", file=sys.stderr) - + subprocess.run([docker, "rm", "--force", name], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + if session_started: + subprocess.run([bregctl, "--format", "json", "dev", "stop", "--remove", + "--docker-bin", docker, str(project)], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--mint", type=Path, help="matching built Mint executable; default: Cargo target debug/mint") + parser.add_argument("--bregctl", type=Path, + help="matching built bregctl executable; default: Cargo target debug/bregctl") + parser.add_argument("--breg", type=Path, + help="matching built breg executable; default: Cargo target debug/breg") parser.add_argument("--with-postgres", action="store_true", help="also exercise persisted approval cutover against an explicitly disposable BREG_TEST_DATABASE_URL") args = parser.parse_args() docker = shutil.which("docker") if not docker: - raise SystemExit("Docker is required for the disposable Keycloak service") + raise SystemExit("Docker is required for the disposable issuer services") target = Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target")) - mint = (args.mint or target / "debug/mint").resolve() - if not mint.is_file(): - raise SystemExit("Build Mint first: cargo build --locked -p registry-mint --bin mint") + bregctl = (args.bregctl or target / "debug/bregctl").resolve() + breg = (args.breg or target / "debug/breg").resolve() + for name, binary in [("bregctl", bregctl), ("breg", breg)]: + if not binary.is_file(): + raise SystemExit(f"Build {name} first: cargo build --locked -p registry-{name} --bin {name}") if args.with_postgres and not os.environ.get("BREG_TEST_DATABASE_URL"): raise SystemExit("--with-postgres requires BREG_TEST_DATABASE_URL pointing to a disposable test database") os.umask(0o077) - # Compile before issuance: a first build must not consume token lifetime. print("Building the focused BREG router test before issuing short-lived tokens", flush=True) test_binaries = {"router": build_test("issuer_portability", "runtime", ROUTER_TEST)} if args.with_postgres: @@ -356,7 +346,7 @@ def stop(signum, _frame): for signum in (signal.SIGTERM, signal.SIGHUP): signal.signal(signum, stop) with tempfile.TemporaryDirectory(prefix="breg-issuer-journey-") as temporary: - journey(Path(temporary).resolve(), str(mint), docker, test_binaries) + journey(Path(temporary).resolve(), str(bregctl), str(breg), docker, test_binaries) if __name__ == "__main__": From ac5bb9e4650cefc07eec944f65dc58bf03a8e6f3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 00:56:56 +0700 Subject: [PATCH 032/120] chore(identity): isolate deferred citizen federation candidate Signed-off-by: Jeremi Joslin --- crates/registry-thunderid-tooling/README.md | 1 - .../examples/contextual-exchange.rs | 84 +- .../registry-thunderid-tooling/src/citizen.rs | 258 -- crates/registry-thunderid-tooling/src/lib.rs | 3 +- .../scripts/test-contextual-exchange.py | 3 - .../identity/thunderid/extension/README.md | 55 - .../identity/thunderid/extension/build.py | 171 -- .../registry-citizen-federation.patch | 2322 ----------------- .../thunderid/extension/test_build.py | 68 - 9 files changed, 17 insertions(+), 2948 deletions(-) delete mode 100644 crates/registry-thunderid-tooling/src/citizen.rs delete mode 100644 products/identity/thunderid/extension/README.md delete mode 100644 products/identity/thunderid/extension/build.py delete mode 100644 products/identity/thunderid/extension/registry-citizen-federation.patch delete mode 100644 products/identity/thunderid/extension/test_build.py diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md index 213708bb86..d850701a66 100644 --- a/crates/registry-thunderid-tooling/README.md +++ b/crates/registry-thunderid-tooling/README.md @@ -108,7 +108,6 @@ flag. Neither operation removes retained files. ### Citizen delegation -Citizen authorization-code delegation uses the reviewed native patch and rebuilt Gate in [the native extension](../../products/identity/thunderid/extension/README.md). `citizen::render` appends the closed provider, human type, consent flow and agent registration before `local::start`; use the build metadata's immutable candidate image ID. Copy the exact destination resource from its client export. This path always requires fresh purpose/field consent and admits no institutional grant namespace. ## Acquire an approved task diff --git a/crates/registry-thunderid-tooling/examples/contextual-exchange.rs b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs index fd660c6e75..d98c7ec6ed 100644 --- a/crates/registry-thunderid-tooling/examples/contextual-exchange.rs +++ b/crates/registry-thunderid-tooling/examples/contextual-exchange.rs @@ -255,20 +255,11 @@ impl Running { image: &self.pin.image, } } - fn start(description: IssuerDescription, image: Option<&str>) -> Result { + fn start(description: IssuerDescription) -> Result { description .validate() .map_err(|_| "Gate0 description validation failed")?; - let mut pin = ThunderIdPin::load().map_err(|_| "upstream pin invalid")?; - if let Some(image) = image { - check( - image.strip_prefix("sha256:").is_some_and(|digest| { - digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()) - }), - "candidate image must be an immutable local image ID", - )?; - pin.image = image.to_owned(); - } + let pin = ThunderIdPin::load().map_err(|_| "upstream pin invalid")?; let this = Self { description, pin }; let root = &this.description.state_root; std::fs::create_dir_all(root.join("secrets")) @@ -280,48 +271,6 @@ impl Running { .map_err(|_| "secret permissions failed")?; } render::render(&this.description).map_err(|_| "native resource rendering failed")?; - if image.is_some() { - let provider = format!( - "http://host.docker.internal:{}", - this.description.exchange_issuers[0] - .issuer - .rsplit(':') - .next() - .and_then(|s| s.split('/').next()) - .unwrap_or("1") - ); - let (_, public_jwks) = key("citizen-client"); - registry_thunderid_tooling::citizen::render( - &this.description, - ®istry_thunderid_tooling::citizen::Federation { - id: "0197aaaa-0000-7000-8000-0000000000d9".into(), - name: "Synthetic citizen federation".into(), - issuer: provider.clone(), - authorization_endpoint: format!("{provider}/authorize"), - token_endpoint: format!("{provider}/token"), - userinfo_endpoint: format!("{provider}/userinfo"), - jwks_endpoint: format!("{provider}/a/jwks"), - client_id: "citizen-upstream".into(), - redirect_uri: format!("http://127.0.0.1:{}/gate/signin", this.description.port), - id_token_alg: "PS256".into(), - userinfo_alg: "PS256".into(), - }, - ®istry_thunderid_tooling::citizen::Client { - client_id: "citizen-agent".into(), - name: "Citizen self-service agent".into(), - public_jwks, - redirect_uri: "http://127.0.0.1:8901/callback".into(), - resource: TARGET.into(), - purpose: "Citizen status lookup".into(), - scope_fields: BTreeMap::from([( - TARGET_SCOPE.into(), - vec!["person-reference".into(), "status".into()], - )]), - require_active_identity: true, - }, - ) - .map_err(|_| "native citizen resource rendering failed")?; - } local::start(&this.session(), std::path::Path::new("docker"), &mut || { false }) @@ -510,7 +459,7 @@ fn exchanged( let claims = verified(&compact, jwks)?; Ok((compact, claims)) } -fn run(root: &Path, image: Option<&str>) -> Result<()> { +fn run(root: &Path) -> Result<()> { std::fs::create_dir(root) .map_err(|_| "state must be a fresh directory; existing state is never removed")?; std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)) @@ -527,10 +476,13 @@ fn run(root: &Path, image: Option<&str>) -> Result<()> { .port(); drop(socket); let started = Instant::now(); - let live = Running::start( - description(root, port, &random(), &keys, [client_jwks, second_jwks]), - image, - )?; + let live = Running::start(description( + root, + port, + &random(), + &keys, + [client_jwks, second_jwks], + ))?; let issuer = live.issuer(); let discovery = loop { if let Ok((200, body)) = http(&format!("{issuer}/.well-known/openid-configuration"), None) { @@ -914,23 +866,19 @@ fn main() { let root = match (args.next().as_deref(), args.next()) { (Some("--state"), Some(root)) => PathBuf::from(root), _ => { - eprintln!("usage: contextual-exchange --state FRESH_ABSOLUTE_DIRECTORY [--image sha256:LOCAL_IMAGE_ID]"); - std::process::exit(2); - } - }; - let image = match (args.next().as_deref(), args.next(), args.next()) { - (None, None, None) => None, - (Some("--image"), Some(image), None) => Some(image), - _ => { - eprintln!("invalid candidate image arguments"); + eprintln!("usage: contextual-exchange --state FRESH_ABSOLUTE_DIRECTORY"); std::process::exit(2); } }; + if args.next().is_some() { + eprintln!("unexpected arguments"); + std::process::exit(2); + } if !root.is_absolute() { eprintln!("state must be absolute"); std::process::exit(2); } - if let Err(reason) = run(&root, image.as_deref()) { + if let Err(reason) = run(&root) { eprintln!("FAIL Gate0: {reason}"); std::process::exit(1); } diff --git a/crates/registry-thunderid-tooling/src/citizen.rs b/crates/registry-thunderid-tooling/src/citizen.rs deleted file mode 100644 index 355e528ae1..0000000000 --- a/crates/registry-thunderid-tooling/src/citizen.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Closed native configuration for citizen authorization-code delegation. -//! Requires the reviewed native federation patch and rebuilt Gate frontend. -use crate::{ - description::{IssuerDescription, MachineClient}, - local::agent_id, - render, ToolingError, -}; -use serde_json::{json, Value}; -use std::collections::{BTreeMap, BTreeSet}; - -/// Public registration of one governed external identity provider. -#[derive(Debug, Clone)] -pub struct Federation { - pub id: String, - pub name: String, - pub issuer: String, - pub authorization_endpoint: String, - pub token_endpoint: String, - pub userinfo_endpoint: String, - pub jwks_endpoint: String, - /// Registered outbound OAuth client. The issuer's RS256 signing key authenticates it. - pub client_id: String, - pub redirect_uri: String, - pub id_token_alg: String, - pub userinfo_alg: String, -} - -/// The complete downstream authority shown at each fresh consent prompt. -#[derive(Debug, Clone)] -pub struct Client { - pub client_id: String, - pub name: String, - pub public_jwks: String, - pub redirect_uri: String, - /// Exact RFC 8707 resource, copied from the destination's registration export. - pub resource: String, - pub purpose: String, - pub scope_fields: BTreeMap>, - /// Optional governed eligibility constant. Never copied from citizen attributes. - pub require_active_identity: bool, -} - -fn invalid() -> ToolingError { - ToolingError::InvalidDescription { reason: "citizen federation requires exact provider URLs, a distinct code-only client, public JWKS, and bounded resource, purpose and field policy" } -} -fn bounded(s: &str, max: usize) -> bool { - !s.trim().is_empty() && s.len() <= max && !s.contains(['\r', '\n']) -} -fn endpoint(s: &str) -> Result { - let u = url::Url::parse(s).map_err(|_| invalid())?; - if u.host_str().is_none() - || !u.username().is_empty() - || u.password().is_some() - || u.fragment().is_some() - || u.query().is_some() - || !(u.scheme() == "https" - || u.scheme() == "http" - && matches!( - u.host_str(), - Some("localhost" | "127.0.0.1" | "host.docker.internal") - )) - { - return Err(invalid()); - } - Ok(u) -} - -/// Append one fixed federation, isolated human type, JIT authentication/consent -/// flow and code-only agent registration to a rendered fresh local session. -/// No arbitrary attributes, grant types, actor mappings or protocol claims are inputs. -pub fn render( - description: &IssuerDescription, - federation: &Federation, - client: &Client, -) -> Result<(), ToolingError> { - description.validate()?; - let native_id = agent_id(&description.session.id, &client.client_id); - let mut check = description.clone(); - check.machine_clients.push(MachineClient { - agent_id: native_id.clone(), - name: client.name.clone(), - description: client.name.clone(), - client_id: client.client_id.clone(), - public_jwks: client.public_jwks.clone(), - attributes: BTreeMap::new(), - token_attributes: vec![], - access_token_lifetime_seconds: 300, - token_exchange: None, - }); - check.validate()?; - let issuer = endpoint(&federation.issuer)?; - for address in [ - &federation.authorization_endpoint, - &federation.token_endpoint, - &federation.userinfo_endpoint, - &federation.jwks_endpoint, - ] { - if endpoint(address)?.origin() != issuer.origin() { - return Err(invalid()); - } - } - endpoint(&federation.redirect_uri)?; - endpoint(&client.redirect_uri)?; - if !bounded(&federation.name, 128) - || !bounded(&federation.client_id, 128) - || !bounded(&client.name, 128) - || !bounded(&client.purpose, 1024) - || !crate::description::valid_uuid(&federation.id) - || description - .exchange_issuers - .iter() - .any(|e| e.id == federation.id || e.issuer == federation.issuer) - || !["RS256", "PS256", "ES256"].contains(&federation.id_token_alg.as_str()) - || !["RS256", "PS256", "ES256"].contains(&federation.userinfo_alg.as_str()) - || client.scope_fields.is_empty() - || client.scope_fields.len() > 32 - { - return Err(invalid()); - } - let server = description - .resource_servers - .iter() - .find(|s| s.identifier == client.resource) - .ok_or_else(invalid)?; - let mut scopes = BTreeSet::new(); - for resource in &server.resources { - let mut names = vec![resource.handle.as_str()]; - let mut parent = resource.parent.as_deref(); - while let Some(handle) = parent { - if names.contains(&handle) { - return Err(invalid()); - } - names.push(handle); - parent = server - .resources - .iter() - .find(|r| r.handle == handle) - .ok_or_else(invalid)? - .parent - .as_deref(); - } - names.reverse(); - for action in &resource.actions { - scopes.insert(format!("{}:{}", names.join(":"), action.handle)); - } - } - for (scope, fields) in &client.scope_fields { - if !scopes.contains(scope) - || fields.is_empty() - || fields.len() > 64 - || fields.iter().any(|f| !bounded(f, 256)) - || fields.iter().collect::>().len() != fields.len() - { - return Err(invalid()); - } - } - let jwks: Value = serde_json::from_str(&client.public_jwks).map_err(|_| invalid())?; - for key in jwks["keys"].as_array().ok_or_else(invalid)? { - if key.get("d").is_some() - || key.get("k").is_some() - || !matches!(key["kty"].as_str(), Some("RSA" | "EC")) - || !key["kid"].as_str().is_some_and(|s| bounded(s, 128)) - { - return Err(invalid()); - } - } - let type_id = agent_id( - &description.session.id, - &format!("citizen-type:{}", federation.id), - ); - let type_name = format!("citizen-{}", &type_id[..8]); - let flow_id = agent_id( - &description.session.id, - &format!("citizen-flow:{}", client.client_id), - ); - let root = description.state_root.join(render::RESOURCES_DIR); - let bootstrap = description.state_root.join(render::BOOTSTRAP_DIR); - let connection = json!({"resource_type":"connection","id":federation.id,"name":federation.name,"type":"oidc","issuer":federation.issuer,"clientId":federation.client_id,"redirectUri":federation.redirect_uri,"authorizationEndpoint":federation.authorization_endpoint,"tokenEndpoint":federation.token_endpoint,"userInfoEndpoint":federation.userinfo_endpoint,"jwksEndpoint":federation.jwks_endpoint,"tokenEndpointAuthMethod":"private_key_jwt","idTokenSigningAlg":federation.id_token_alg,"userInfoSigningAlg":federation.userinfo_alg,"requiredClaims":["person_reference"],"scopes":["openid"],"prompt":"login","tokenExchangeEnabled":false,"idJagEnabled":false,"attributeConfiguration":{"user_type_resolution":{"default":type_name},"user_type_attribute_mappings":[{"user_type":type_name,"attributes":[{"external_attribute":"sub","local_attribute":"sub"},{"external_attribute":"person_reference","local_attribute":"person_reference"}]}]}}); - let user_type = json!({"resource_type":"user_type","id":type_id,"category":"user","name":type_name,"ouHandle":description.organization_unit.handle,"allowSelfRegistration":true,"schema":{"sub":{"type":"string","required":true},"registry_federation_issuer":{"type":"string","required":true},"person_reference":{"type":"string","required":true}}}); - let consent_input = json!({"ref":"consent_input","identifier":"consent_decisions","type":"CONSENT_INPUT","required":true}); - let flow = json!({"resource_type":"flow","id":flow_id,"name":format!("{} citizen consent",client.name),"handle":format!("citizen-{}",&flow_id[..8]),"flowType":"AUTHENTICATION","nodes":[ - {"id":"start","type":"START","onSuccess":"federation"}, - {"id":"federation","type":"TASK_EXECUTION","properties":{"idpId":federation.id,"allowAuthenticationWithoutLocalUser":true},"executor":{"name":"OIDCAuthExecutor"},"onSuccess":"provision"}, - {"id":"provision","type":"TASK_EXECUTION","condition":{"key":"{{ctx(userEligibleForProvisioning)}}","value":"true","onSkip":"consent"},"executor":{"name":"ProvisioningExecutor"},"onSuccess":"consent"}, - {"id":"consent","type":"TASK_EXECUTION","executor":{"name":"ConsentExecutor"},"onSuccess":"assert","onIncomplete":"prompt"}, - {"id":"prompt","type":"PROMPT","meta":{"components":[{"type":"TEXT","id":"heading","label":format!("{} requests your permission",client.name),"variant":"HEADING_1"},{"type":"BLOCK","id":"consent_block","components":[{"id":"consent_input","ref":"consent_decisions","type":"CONSENT_INPUT","required":true},{"type":"ACTION","id":"consent_action_deny","label":"Decline this request","variant":"SECONDARY","eventType":"SUBMIT"},{"type":"ACTION","id":"consent_action_allow","label":"Allow","variant":"PRIMARY","eventType":"SUBMIT"}]}]},"prompts":[{"inputs":[consent_input],"action":{"ref":"consent_action_allow","nextNode":"consent"}},{"inputs":[consent_input],"action":{"ref":"consent_action_deny","nextNode":"consent"}}]}, - {"id":"assert","type":"TASK_EXECUTION","executor":{"name":"AuthAssertExecutor"},"onSuccess":"end"},{"id":"end","type":"END"}]}); - let mut policy = json!({"purpose":client.purpose,"resource":client.resource,"scopeFields":client.scope_fields,"subjectAttribute":"person_reference"}); - if client.require_active_identity { - policy["identityStatus"] = json!("active"); - } - let agent = json!({"resource_type":"agent","id":native_id,"type":"default","ouHandle":description.organization_unit.handle,"name":client.name,"authFlowId":flow_id,"allowedUserTypes":[type_name],"assertion":{"validityPeriod":300,"userAttributes":["person_reference"]},"loginConsent":{"validityPeriod":300,"delegation":policy},"inboundAuthConfig":[{"type":"oauth2","config":{"clientId":client.client_id,"grantTypes":["authorization_code"],"responseTypes":["code"],"redirectUris":[client.redirect_uri],"pkceRequired":true,"publicClient":false,"tokenEndpointAuthMethod":"private_key_jwt","certificate":{"type":"JWKS","value":client.public_jwks},"token":{"accessToken":{"userConfig":{"validityPeriod":300,"attributes":["person_reference"]}}}}}]}); - for (path, doc) in [ - ( - root.join("connections") - .join(format!("{}.yaml", federation.id)), - connection, - ), - ( - bootstrap.join("user-types").join(format!("{type_id}.yaml")), - user_type, - ), - ( - bootstrap.join("flows").join(format!("{flow_id}.yaml")), - flow, - ), - ( - bootstrap.join("agents").join(format!("{native_id}.yaml")), - agent, - ), - ] { - render::write_owner_only( - &path, - serde_norway::to_string(&doc) - .map_err(|_| invalid())? - .as_bytes(), - )?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn citizen_configuration_rejects_authority_and_transport_expansion_before_writing() { - let description = crate::testing::synthetic_description(); - let mut provider = Federation { - id: "0197aaaa-0000-7000-8000-0000000000d9".into(), - name: "Synthetic provider".into(), - issuer: "https://provider.example".into(), - authorization_endpoint: "https://provider.example/authorize".into(), - token_endpoint: "https://provider.example/token".into(), - userinfo_endpoint: "https://provider.example/userinfo".into(), - jwks_endpoint: "https://provider.example/jwks".into(), - client_id: "outbound".into(), - redirect_uri: "http://127.0.0.1:8090/gate/signin".into(), - id_token_alg: "PS256".into(), - userinfo_alg: "PS256".into(), - }; - let client = Client { - client_id: "citizen".into(), - name: "Citizen agent".into(), - public_jwks: description.machine_clients[0].public_jwks.clone(), - redirect_uri: "https://agent.example/callback".into(), - resource: description.resource_servers[0].identifier.clone(), - purpose: "Status lookup".into(), - scope_fields: BTreeMap::from([("unregistered:scope".into(), vec!["status".into()])]), - require_active_identity: true, - }; - assert!(render(&description, &provider, &client).is_err()); - provider.token_endpoint = "https://other.example/token".into(); - assert!(render(&description, &provider, &client).is_err()); - provider.token_endpoint = "https://provider.example/token".into(); - provider.userinfo_alg = "none".into(); - assert!(render(&description, &provider, &client).is_err()); - } -} diff --git a/crates/registry-thunderid-tooling/src/lib.rs b/crates/registry-thunderid-tooling/src/lib.rs index bc12a02ee7..3289b20564 100644 --- a/crates/registry-thunderid-tooling/src/lib.rs +++ b/crates/registry-thunderid-tooling/src/lib.rs @@ -1,4 +1,4 @@ -//! Tooling-only support for pinned ThunderID and its reviewed native extensions. +//! Tooling-only support for the pinned upstream ThunderID release. //! //! This crate exists so adopter CLIs and integration tests can stand up one //! development session's ThunderID container, render the pinned release's @@ -26,7 +26,6 @@ //! tooling, examples, and CI all read that one pin. pub mod bootstrap; -pub mod citizen; pub mod container; pub mod description; pub mod grant; diff --git a/products/identity/scripts/test-contextual-exchange.py b/products/identity/scripts/test-contextual-exchange.py index f90657d339..3fe5145dab 100755 --- a/products/identity/scripts/test-contextual-exchange.py +++ b/products/identity/scripts/test-contextual-exchange.py @@ -19,7 +19,6 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--driver", type=Path, help="already-built contextual-exchange example") parser.add_argument("--state", type=Path, help="fresh absolute state directory") - parser.add_argument("--image", help="immutable local candidate sha256 image ID") args = parser.parse_args() root = Path(__file__).resolve().parents[3] state = args.state or Path(tempfile.gettempdir()) / f"registry-contextual-gate0-{uuid.uuid4().hex}" @@ -37,8 +36,6 @@ def main() -> int: driver = target / "debug/examples/contextual-exchange" print(f"Gate0 retained state: {state}", flush=True) command = [str(driver.resolve()), "--state", str(state)] - if args.image: - command.extend(["--image", args.image]) return subprocess.run(command, cwd=root, check=False).returncode diff --git a/products/identity/thunderid/extension/README.md b/products/identity/thunderid/extension/README.md deleted file mode 100644 index 8d43af569d..0000000000 --- a/products/identity/thunderid/extension/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Native citizen federation - -Institutional RFC 8693 exchange uses the pinned upstream issuer. Citizen delegation additionally requires the source patch in this directory and the rebuilt native Gate frontend. The patch adds outbound private-key JWT authentication, S256 PKCE, essential claims, strict signed OIDC response validation, and consent-bound citizen authorization codes inside ThunderID. It does not add another issuer service or a custom delegation grant. - -## Build and verify - -Run from the Registry Stack root with Docker, Go and the upstream-pinned pnpm version available: - -```sh -python3 products/identity/thunderid/extension/build.py --output /absolute/fresh/build --image -``` - -Use `--archive /path/to/cached.tar.gz` to reuse a download. The builder verifies the archive against `crates/registry-thunderid-tooling/thunderid-version.json`, applies a captured patch, builds the native server and Gate, and creates a new local candidate image. It refuses existing output directories. `build.json` records the upstream commit, archive and patch checksums, binary and Gate asset checksums, frontend lockfile checksum, tool versions, base image digest and candidate image ID. No service is started. Use that immutable image ID in the owning session's `container::Session.image`. - -The patch carries focused native Go tests with real local authorization, token, JWKS and signed UserInfo endpoints. In the prepared `upstream/backend` directory, run: - -```sh -go test -mod=readonly ./internal/authn/oauth ./internal/authn/oidc ./internal/authn/consent ./internal/connection ./internal/idp ./internal/flow/executor ./internal/oauth/oauth2/granthandlers ./internal/oauth/oauth2/authz ./internal/oauth/oauth2/token ./pkg/thunderidengine/providers -``` - -In `upstream/frontend/packages/design`, run: - -```sh -pnpm exec vitest run src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx src/components/flow/__tests__/FlowComponentRenderer.test.tsx -``` - -Run the institutional regression against the candidate using `products/identity/scripts/test-contextual-exchange.py --image sha256:LOCAL_IMAGE_ID`. Its candidate mode also bootstraps the native citizen connection, user type, flow and code-only client. This establishes configuration acceptance and institutional interoperability; a deployment must separately verify its real identity provider and destination journey. - -## Governed configuration - -`registry_thunderid_tooling::citizen::render` appends a closed native citizen registration to a fresh session after `render::render` and before `local::start`. Supply the provider's exact issuer, authorization/token/UserInfo/JWKS endpoints, registered outbound client ID and redirect URI, expected signed response algorithms, and the agent's own public JWKS and exact callback URI. The helper emits a separate human type and a federation, JIT provisioning, consent and assertion flow. Provider identity is bound to the verified issuer and pairwise `sub`; account linking by mutable claims is disabled on this path. - -The provider must supply a governed opaque `person_reference` in signed UserInfo. It remains distinct from OIDC `sub`. Only the consent-approved verified value can become `registry_subject_person_reference`. Optional `require_active_identity` emits the governed constant `registry_identity_status=active`. It never reads this constant from citizen input. Scope-to-field descriptions, purpose and the exact destination resource come from the owning product's authorization policy. Copy a development resource from that session's client export; do not derive it from a project name. - -The native policy shape is: - -```yaml -loginConsent: - validityPeriod: 300 - delegation: - purpose: citizen-self-service - resource: urn:destination:exact-registration - scopeFields: - registry:population:self-service: [person-reference, status] - subjectAttribute: person_reference - identityStatus: active -``` - -Citizen agents admit only `authorization_code`, exact redirects, PKCE and at most 300-second access tokens. Every authorization requires fresh consent. Tokens carry the citizen subject, authenticated OAuth `client_id`, native registered agent entity ID in `act.sub`, `registry_actor_kind=agent`, and governed purpose. They cannot carry institutional grant attributes. Code redemption checks the exact current consent authorization record and unchanged resource, scopes, field/purpose policy and duration. Missing cache, unavailable consent, withdrawal, expiry or replacement approval denies old codes. - -## Citizen control and provider keys - -The agent's normal authorization link opens the native Gate flow. After authenticating with the configured identity provider, the citizen sees the agent, destination, purpose, requested fields and duration. **Decline this request** denies this issuance and preserves earlier consent. **Withdraw this agent’s access** withdraws that authenticated citizen's current consent for this agent and invalidates outstanding codes. Reapproving later does not revive old codes. Already issued access tokens may remain usable until their expiry, at most five minutes. Withdrawal is available in the native human flow without requiring the agent to return a bearer token. - -Outbound `private_key_jwt` reuses the injected issuer signing service, with RS256, the registered signing `kid`, exact token-endpoint audience, fresh `jti`, and 60-second assertions. Register its public key with the identity provider. Rotating that issuer key also requires updating the provider's outbound client registration. Endpoint origins must match the exact configured issuer; redirects are refused. HTTPS is required except explicitly authored local HTTP fixtures. ID Tokens and UserInfo must be signed JWS with the configured algorithms, matching issuer, audience, key and subject; ID Token nonce and both authorization transactions' state/PKCE bindings are verified. Encrypted UserInfo is not enabled by this profile. diff --git a/products/identity/thunderid/extension/build.py b/products/identity/thunderid/extension/build.py deleted file mode 100644 index 2914207117..0000000000 --- a/products/identity/thunderid/extension/build.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -"""Build the Registry ThunderID extension from verified upstream source. - -Only creates a fresh output directory. Never replaces an existing build or -starts a service. Use --image to build a local candidate container as well. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import os -from pathlib import Path -import shutil -import subprocess -import tarfile -import urllib.request - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[3] -PIN = ROOT / "crates/registry-thunderid-tooling/thunderid-version.json" - - -def sha256(path: Path) -> str: - with path.open("rb") as stream: - return hashlib.file_digest(stream, "sha256").hexdigest() - - -def tree_sha256(root: Path) -> str: - digest = hashlib.sha256() - for path in sorted(root.rglob("*")): - if path.is_symlink(): - raise ValueError("build assets must not contain symlinks") - if path.is_file(): - digest.update(path.relative_to(root).as_posix().encode() + b"\0") - digest.update(bytes.fromhex(sha256(path))) - return digest.hexdigest() - - -def extract_source(archive: Path, destination: Path, expected_digest: str) -> None: - if sha256(archive) != expected_digest: - raise ValueError("upstream archive checksum mismatch") - # Verify all paths before extraction; create internal symlinks last. - with tarfile.open(archive, "r:gz") as source: - members = source.getmembers() - roots = {Path(member.name).parts[0] for member in members if member.name} - if len(roots) != 1: - raise ValueError("upstream archive must contain one source root") - for member in members: - path = Path(member.name) - if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile() or member.issym()): - raise ValueError("unsupported upstream archive entry") - if member.issym(): - relative = Path(*path.parts[1:]) - resolved = (destination / relative.parent / member.linkname).resolve() - if Path(member.linkname).is_absolute() or not resolved.is_relative_to(destination.resolve()): - raise ValueError("unsupported upstream archive link") - destination.mkdir() - for member in members: - if member.issym(): - continue - relative = Path(*Path(member.name).parts[1:]) - target = destination / relative - if member.isdir(): - target.mkdir(parents=True, exist_ok=True) - else: - target.parent.mkdir(parents=True, exist_ok=True) - with source.extractfile(member) as data, target.open("xb") as output: - shutil.copyfileobj(data, output) - target.chmod(member.mode & 0o755) - for member in members: - if member.issym(): - target = destination / Path(*Path(member.name).parts[1:]) - target.parent.mkdir(parents=True, exist_ok=True) - target.symlink_to(member.linkname) - - -def run(arguments: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None: - subprocess.run(arguments, cwd=cwd, env=env, check=True) - - -def apply_source_patch(source: Path, patch: Path) -> None: - # Without a local Git root, git apply can discover the enclosing Registry - # repository and silently skip paths outside the current subdirectory. - run(["git", "init", "--quiet"], cwd=source) - run(["git", "apply", "--check", str(patch)], cwd=source) - run(["git", "apply", str(patch)], cwd=source) - run(["git", "apply", "--reverse", "--check", str(patch)], cwd=source) - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--archive", type=Path, help="cached source archive, verified against the pin") - parser.add_argument("--output", type=Path, required=True, help="fresh build directory") - parser.add_argument("--prepare-only", action="store_true") - parser.add_argument("--image", action="store_true", help="build a local image for this host's Docker architecture") - args = parser.parse_args() - if args.prepare_only and args.image: - parser.error("--prepare-only and --image cannot be combined") - output = args.output.resolve() - if output.exists(): - parser.error("output already exists; choose a fresh directory") - pin = json.loads(PIN.read_text()) - output.mkdir(parents=True) - archive = args.archive.resolve() if args.archive else output / "upstream.tar.gz" - if not args.archive: - urllib.request.urlretrieve(pin["source"]["archiveUrl"], archive) - source = output / "upstream" - extract_source(archive, source, pin["source"]["archiveSha256"]) - patch = HERE / "registry-citizen-federation.patch" - # Apply a captured copy so the metadata always describes the applied bytes. - shutil.copyfile(patch, output / "thunderid.patch") - apply_source_patch(source, output / "thunderid.patch") - metadata = { - "schema": "registry.thunderid-extension-build/v1", - "upstreamVersion": pin["version"], - "upstreamCommit": pin["source"]["commit"], - "archiveSha256": pin["source"]["archiveSha256"], - "patchSha256": sha256(output / "thunderid.patch"), - "baseImage": pin["image"], - } - if not args.prepare_only: - environment = os.environ.copy() - if args.image: - architecture = subprocess.check_output( - ["docker", "info", "--format", "{{.Architecture}}"], text=True - ).strip() - go_arch = {"aarch64": "arm64", "arm64": "arm64", "x86_64": "amd64", "amd64": "amd64"}.get(architecture) - if go_arch is None: - raise ValueError("supported Docker architectures are amd64 and arm64") - environment.update(GOOS="linux", GOARCH=go_arch, CGO_ENABLED="0") - binary = output / "thunderid" - run(["go", "build", "-mod=readonly", "-trimpath", "-o", str(binary), "./cmd/server"], - cwd=source / "backend", env=environment) - metadata["binarySha256"] = sha256(binary) - metadata["goVersion"] = subprocess.check_output(["go", "version"], text=True).strip() - frontend = source / "frontend" - package = json.loads((source / "package.json").read_text()) - expected_pnpm = package["devEngines"]["packageManager"]["version"] - actual_pnpm = subprocess.check_output(["pnpm", "--version"], cwd=frontend, text=True).strip() - if actual_pnpm != expected_pnpm: - raise ValueError("pnpm version does not match the pinned upstream package manager") - run(["pnpm", "install", "--frozen-lockfile"], cwd=frontend) - run(["pnpm", "exec", "turbo", "run", "build", "--filter=@thunderid/gate..."], cwd=frontend) - gate = frontend / "apps/gate/dist" - if not (gate / "index.html").is_file(): - raise ValueError("native Gate frontend build is missing") - metadata["gateTreeSha256"] = tree_sha256(gate) - metadata["frontendLockSha256"] = sha256(source / "pnpm-lock.yaml") - metadata["nodeVersion"] = subprocess.check_output(["node", "--version"], text=True).strip() - metadata["pnpmVersion"] = actual_pnpm - if args.image: - context = output / "image" - context.mkdir() - shutil.copyfile(binary, context / "thunderid") - shutil.copytree(gate, context / "gate") - (context / "Dockerfile").write_text( - f"FROM {pin['image']}\n" - "COPY --chmod=0755 --chown=10001:10001 thunderid /opt/thunderid/thunderid\n" - "USER root\nRUN rm -rf /opt/thunderid/apps/gate\n" - "COPY --chown=10001:10001 gate /opt/thunderid/apps/gate\nUSER 10001\n" - ) - run(["docker", "build", "--iidfile", str(output / "image-id"), str(context)], cwd=output) - metadata["imageId"] = (output / "image-id").read_text().strip() - (output / "build.json").write_text(json.dumps(metadata, indent=2) + "\n") - print(output / "build.json") - - -if __name__ == "__main__": - main() diff --git a/products/identity/thunderid/extension/registry-citizen-federation.patch b/products/identity/thunderid/extension/registry-citizen-federation.patch deleted file mode 100644 index 5d4faf1e5d..0000000000 --- a/products/identity/thunderid/extension/registry-citizen-federation.patch +++ /dev/null @@ -1,2322 +0,0 @@ -diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go ---- a/backend/cmd/server/servicemanager.go -+++ b/backend/cmd/server/servicemanager.go -@@ -478,7 +478,7 @@ - tokenValidator, err := oauth.Initialize(mux, actorProvider, authnProvider, jwtService, jweService, - flowExecService, observabilitySvc, runtimeCryptoSvc, ouService, attributeCacheService, authZService, - resourceServerProvider, i18nService, idpService, dpopVerifier, -- runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, oauthCfg) -+ runtimeStoreProvider, transactioner, revocationEnforcer, revocationSvc, oauthCfg, consentEnforcer) - fatalOnError(ctx, logger, err, "Failed to initialize OAuth services") - - // Initialized after the OAuth services because credential issuance validates the presented -diff --git a/backend/internal/authn/common/model.go b/backend/internal/authn/common/model.go ---- a/backend/internal/authn/common/model.go -+++ b/backend/internal/authn/common/model.go -@@ -63,7 +63,8 @@ - // AuthorizationData holds authorization flow parameters exchanged during federated authentication. - type AuthorizationData struct { - // Code is the authorization code received from the identity provider -- Code string -+ CodeVerifier string -+ Code string - // Nonce is the nonce parameter received from the identity provider (if applicable) - Nonce string - } -diff --git a/backend/internal/authn/consent/delegation.go b/backend/internal/authn/consent/delegation.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/authn/consent/delegation.go -@@ -0,0 +1,98 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package consent -+ -+import ( -+ "context" -+ "github.com/thunder-id/thunderid/internal/consent" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+ "time" -+) -+ -+// ValidateConsentAuthorization reads current native consent at every code -+// redemption. A replacement authorization row can never revive an older code. -+func (s *consentEnforcerService) ValidateConsentAuthorization(ctx context.Context, b providers.DelegationConsentBinding) *tidcommon.ServiceError { -+ if s.consentService == nil || b.ConsentID == "" || b.AuthorizationID == "" || b.UserID == "" || b.AppID == "" || b.Deadline <= time.Now().Unix() { -+ return &ErrorConsentSessionInvalid -+ } -+ records, err := s.consentService.SearchConsents(ctx, consent.ConsentFilter{GroupID: b.AppID, UserID: b.UserID, ConsentStatus: consent.ConsentStatusActive}) -+ if err != nil { -+ return &ErrorConsentSearchFailed -+ } -+ for _, record := range records { -+ if record == nil || record.ID != b.ConsentID || record.GroupID != b.AppID || record.Status != consent.ConsentStatusActive || record.ValidityTime < b.Deadline { -+ continue -+ } -+ authorized := false -+ for _, a := range record.Authorizations { -+ if a.ID == b.AuthorizationID && a.UserID == b.UserID && a.Status == consent.AuthorizationStatusApproved { -+ authorized = true -+ } -+ } -+ if !authorized { -+ continue -+ } -+ approved := map[string]map[string]bool{} -+ for _, purpose := range record.Purposes { -+ elements := map[string]bool{} -+ for _, element := range purpose.Elements { -+ elements[element.Name] = element.IsUserApproved -+ } -+ approved[purpose.Name] = elements -+ } -+ valid := true -+ for _, scope := range b.Scopes { -+ if !approved[consent.PermissionPurposeName(b.AppID)][scope] { -+ valid = false -+ } -+ } -+ for _, attr := range b.Attributes { -+ if !approved[consent.AttributePurposeName(b.AppID)][attr] { -+ valid = false -+ } -+ } -+ if valid { -+ return nil -+ } -+ } -+ return &ErrorConsentSessionInvalid -+} -+ -+// WithdrawAuthenticatedConsent is called only after the interactive flow has -+// verified a human. The caller supplies server-resolved user and application IDs. -+func (s *consentEnforcerService) WithdrawAuthenticatedConsent(ctx context.Context, appID, userID string) *tidcommon.ServiceError { -+ if s.consentService == nil || appID == "" || userID == "" { -+ return &ErrorConsentSessionInvalid -+ } -+ records, err := s.consentService.SearchConsents(ctx, consent.ConsentFilter{GroupID: appID, UserID: userID, ConsentStatus: consent.ConsentStatusActive}) -+ if err != nil { -+ return &ErrorConsentSearchFailed -+ } -+ for _, record := range records { -+ if record == nil || record.GroupID != appID { -+ return &ErrorConsentSessionInvalid -+ } -+ for _, authorization := range record.Authorizations { -+ if authorization.UserID != userID { -+ return &ErrorConsentSessionInvalid -+ } -+ } -+ purposes := make([]consent.ConsentPurposeItem, 0, len(record.Purposes)) -+ for _, purpose := range record.Purposes { -+ denied := consent.ConsentPurposeItem{Name: purpose.Name} -+ for _, element := range purpose.Elements { -+ element.IsUserApproved = false -+ denied.Elements = append(denied.Elements, element) -+ } -+ purposes = append(purposes, denied) -+ } -+ _, err := s.consentService.UpdateConsent(ctx, record.ID, &consent.ConsentRequest{GroupID: appID, ValidityTime: time.Now().Unix(), Purposes: purposes, -+ Authorizations: []consent.ConsentAuthorizationRequest{{UserID: userID, Type: consent.AuthorizationTypeAuthorization, Status: consent.AuthorizationStatusRejected}}}) -+ if err != nil { -+ return &ErrorConsentSessionInvalid -+ } -+ } -+ return nil -+} -diff --git a/backend/internal/authn/consent/delegation_test.go b/backend/internal/authn/consent/delegation_test.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/authn/consent/delegation_test.go -@@ -0,0 +1,87 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+package consent -+ -+import ( -+ "context" -+ "github.com/stretchr/testify/mock" -+ "github.com/stretchr/testify/require" -+ "github.com/thunder-id/thunderid/internal/consent" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+ "github.com/thunder-id/thunderid/tests/mocks/consentmock" -+ "testing" -+ "time" -+) -+ -+func TestCitizenCodeWithdrawalCannotBeUndoneByReapproval(t *testing.T) { -+ binding := providers.DelegationConsentBinding{ConsentID: "consent", AuthorizationID: "approval-one", AppID: "agent", UserID: "citizen", Deadline: time.Now().Unix() + 120, Scopes: []string{"records:read"}, Attributes: []string{"person_reference"}} -+ record := &consent.Consent{ID: "consent", GroupID: "agent", Status: consent.ConsentStatusActive, ValidityTime: binding.Deadline, -+ Authorizations: []consent.ConsentAuthorization{{ID: "approval-one", UserID: "citizen", Status: consent.AuthorizationStatusApproved}}, -+ Purposes: []consent.ConsentPurposeItem{{Name: "permissions:agent", Elements: []consent.ConsentElementApproval{{Name: "records:read", IsUserApproved: true}}}, {Name: "attributes:agent", Elements: []consent.ConsentElementApproval{{Name: "person_reference", IsUserApproved: true}}}}} -+ store := consentmock.NewConsentServiceInterfaceMock(t) -+ unavailable := false -+ store.On("SearchConsents", mock.Anything, consent.ConsentFilter{GroupID: "agent", UserID: "citizen", ConsentStatus: consent.ConsentStatusActive}).Return( -+ func(context.Context, consent.ConsentFilter) []*consent.Consent { return []*consent.Consent{record} }, -+ func(context.Context, consent.ConsentFilter) *tidcommon.ServiceError { -+ if unavailable { -+ return &tidcommon.InternalServerError -+ } -+ return nil -+ }) -+ svc := &consentEnforcerService{consentService: store} -+ require.Nil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ record.Authorizations[0].Status = consent.AuthorizationStatusRejected -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ record.Authorizations[0].Status = consent.AuthorizationStatusApproved -+ record.Authorizations[0].ID = "approval-two" -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding), "reapproval revived an old code") -+ binding.AuthorizationID = "approval-two" -+ require.Nil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ record.Purposes[0].Elements[0].IsUserApproved = false -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ record.Purposes[0].Elements[0].IsUserApproved = true -+ record.Purposes[1].Elements[0].IsUserApproved = false -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ record.Purposes[1].Elements[0].IsUserApproved = true -+ unavailable = true -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+ unavailable = false -+ binding.Deadline = time.Now().Unix() - 1 -+ require.NotNil(t, svc.ValidateConsentAuthorization(context.Background(), binding)) -+} -+ -+func TestAuthenticatedWithdrawalRejectsOnlyCurrentCitizenConsent(t *testing.T) { -+ for _, scenario := range []string{"ok", "other-user", "other-app", "lookup-failed", "write-failed"} { -+ t.Run(scenario, func(t *testing.T) { -+ record := &consent.Consent{ID: "current", GroupID: "agent", Authorizations: []consent.ConsentAuthorization{{ID: "old", UserID: "citizen", Status: consent.AuthorizationStatusApproved}}, Purposes: []consent.ConsentPurposeItem{{Name: "permissions:agent", Elements: []consent.ConsentElementApproval{{Name: "records:read", IsUserApproved: true}}}}} -+ if scenario == "other-user" { -+ record.Authorizations[0].UserID = "other" -+ } -+ if scenario == "other-app" { -+ record.GroupID = "other" -+ } -+ store := consentmock.NewConsentServiceInterfaceMock(t) -+ var lookupErr *tidcommon.ServiceError -+ if scenario == "lookup-failed" { -+ lookupErr = &tidcommon.InternalServerError -+ } -+ store.On("SearchConsents", mock.Anything, consent.ConsentFilter{GroupID: "agent", UserID: "citizen", ConsentStatus: consent.ConsentStatusActive}).Return([]*consent.Consent{record}, lookupErr) -+ if scenario == "ok" || scenario == "write-failed" { -+ var writeErr *tidcommon.ServiceError -+ if scenario == "write-failed" { -+ writeErr = &tidcommon.InternalServerError -+ } -+ store.On("UpdateConsent", mock.Anything, "current", mock.MatchedBy(func(req *consent.ConsentRequest) bool { -+ return req.GroupID == "agent" && req.ValidityTime <= time.Now().Unix() && len(req.Authorizations) == 1 && req.Authorizations[0].UserID == "citizen" && req.Authorizations[0].Status == consent.AuthorizationStatusRejected && !req.Purposes[0].Elements[0].IsUserApproved -+ })).Return(record, writeErr).Once() -+ } -+ err := (&consentEnforcerService{consentService: store}).WithdrawAuthenticatedConsent(context.Background(), "agent", "citizen") -+ if scenario == "ok" { -+ require.Nil(t, err) -+ } else { -+ require.NotNil(t, err) -+ } -+ }) -+ } -+} -diff --git a/backend/internal/authn/oauth/federation.go b/backend/internal/authn/oauth/federation.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/authn/oauth/federation.go -@@ -0,0 +1,69 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package oauth -+ -+import ( -+ "context" -+ "errors" -+ "net/http" -+ "net/url" -+ "slices" -+ "strings" -+ "time" -+) -+ -+type federationExchangeKey struct{} -+type federationExchange struct{ Verifier, Assertion string } -+ -+// WithFederationExchange carries server-held transaction material to the token -+// request builder. It is never an authorization request or browser parameter. -+func WithFederationExchange(ctx context.Context, verifier, assertion string) context.Context { -+ return context.WithValue(ctx, federationExchangeKey{}, federationExchange{verifier, assertion}) -+} -+ -+// FederationHTTPClient sends only to governed endpoints. It refuses redirects, -+// including same-origin redirects that could change the assertion audience. -+func FederationHTTPClient() *http.Client { -+ return &http.Client{Timeout: 15 * time.Second, -+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} -+} -+ -+// ValidateFederation keeps private_key_jwt connections complete and explicit. -+// Loopback HTTP is only for locally authored protocol fixtures. -+func (c *OAuthClientConfig) ValidateFederation() error { -+ if c.TokenEndpointAuthMethod == "" { -+ return nil -+ } -+ invalid := errors.New("private_key_jwt federation requires exact issuer endpoints, signed responses, and required claims") -+ if c.TokenEndpointAuthMethod != "private_key_jwt" || c.ClientSecret != "" || c.ClientID == "" || -+ c.RedirectURI == "" || !slices.Contains(c.Scopes, "openid") || len(c.RequiredClaims) == 0 || len(c.RequiredClaims) > 32 { -+ return invalid -+ } -+ for _, alg := range []string{c.IDTokenSigningAlg, c.UserInfoSigningAlg} { -+ if !slices.Contains([]string{"RS256", "PS256", "ES256"}, alg) { -+ return invalid -+ } -+ } -+ issuer, err := url.Parse(c.Issuer) -+ if err != nil || issuer.Host == "" || issuer.User != nil || issuer.RawQuery != "" || issuer.Fragment != "" { -+ return invalid -+ } -+ if issuer.Scheme != "https" && !(issuer.Scheme == "http" && slices.Contains([]string{"localhost", "127.0.0.1", "::1", "host.docker.internal"}, issuer.Hostname())) { -+ return invalid -+ } -+ for _, endpoint := range []string{c.OAuthEndpoints.AuthorizationEndpoint, c.OAuthEndpoints.TokenEndpoint, c.OAuthEndpoints.UserInfoEndpoint, c.OAuthEndpoints.JwksEndpoint} { -+ parsed, err := url.Parse(endpoint) -+ if err != nil || parsed.Scheme != issuer.Scheme || parsed.Host != issuer.Host || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { -+ return invalid -+ } -+ } -+ seen := map[string]bool{} -+ for _, claim := range c.RequiredClaims { -+ if claim == "" || len(claim) > 128 || strings.ContainsAny(claim, " \t\r\n") || seen[claim] { -+ return invalid -+ } -+ seen[claim] = true -+ } -+ return nil -+} -diff --git a/backend/internal/authn/oauth/model.go b/backend/internal/authn/oauth/model.go ---- a/backend/internal/authn/oauth/model.go -+++ b/backend/internal/authn/oauth/model.go -@@ -14,12 +14,17 @@ - - // OAuthClientConfig holds the OAuth client configuration details. - type OAuthClientConfig struct { -- ClientID string -- ClientSecret string -- RedirectURI string -- Scopes []string -- OAuthEndpoints OAuthEndpoints -- AdditionalParams map[string]string -+ Issuer string -+ TokenEndpointAuthMethod string -+ IDTokenSigningAlg string -+ UserInfoSigningAlg string -+ RequiredClaims []string -+ ClientID string -+ ClientSecret string -+ RedirectURI string -+ Scopes []string -+ OAuthEndpoints OAuthEndpoints -+ AdditionalParams map[string]string - } - - // TokenResponse represents the token endpoint response body. -diff --git a/backend/internal/authn/oauth/service.go b/backend/internal/authn/oauth/service.go ---- a/backend/internal/authn/oauth/service.go -+++ b/backend/internal/authn/oauth/service.go -@@ -174,7 +174,11 @@ - return nil, svcErr - } - -- tokenResp, svcErr := sendTokenRequest(httpReq, s.httpClient, logger) -+ client := s.httpClient -+ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { -+ client = FederationHTTPClient() -+ } -+ tokenResp, svcErr := sendTokenRequest(httpReq, client, logger) - if svcErr != nil { - return nil, svcErr - } -diff --git a/backend/internal/authn/oauth/utils.go b/backend/internal/authn/oauth/utils.go ---- a/backend/internal/authn/oauth/utils.go -+++ b/backend/internal/authn/oauth/utils.go -@@ -57,7 +57,17 @@ - oAuthClientConfig.OAuthEndpoints.UserEmailEndpoint = value - case idpPkg.PropJwksEndpoint: - oAuthClientConfig.OAuthEndpoints.JwksEndpoint = value -- case idpPkg.PropIssuer, idpPkg.PropTokenExchangeEnabled: -+ case idpPkg.PropIssuer: -+ oAuthClientConfig.Issuer = value -+ case idpPkg.PropTokenEndpointAuthMethod: -+ oAuthClientConfig.TokenEndpointAuthMethod = value -+ case idpPkg.PropIDTokenSigningAlg: -+ oAuthClientConfig.IDTokenSigningAlg = value -+ case idpPkg.PropUserInfoSigningAlg: -+ oAuthClientConfig.UserInfoSigningAlg = value -+ case idpPkg.PropRequiredClaims: -+ oAuthClientConfig.RequiredClaims = sysutils.ParseStringArray(value, ",") -+ case idpPkg.PropTokenExchangeEnabled: - // Server-side configuration consumed elsewhere (token exchange). - // These must not be forwarded as query parameters on the external authorize request. - default: -@@ -75,6 +85,9 @@ - } - } - -+ if err := oAuthClientConfig.ValidateFederation(); err != nil { -+ return nil, err -+ } - return &oAuthClientConfig, nil - } - -@@ -83,12 +96,22 @@ - *http.Request, *tidcommon.ServiceError) { - form := url.Values{} - form.Set(oauth2const.RequestParamClientID, oAuthClientConfig.ClientID) -- form.Set(oauth2const.RequestParamClientSecret, oAuthClientConfig.ClientSecret) -+ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { -+ binding, ok := ctx.Value(federationExchangeKey{}).(federationExchange) -+ if !ok || len(binding.Verifier) < 43 || binding.Assertion == "" { -+ return nil, &ErrorInvalidTokenResponse -+ } -+ form.Set("code_verifier", binding.Verifier) -+ form.Set("client_assertion", binding.Assertion) -+ form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") -+ } else { -+ form.Set(oauth2const.RequestParamClientSecret, oAuthClientConfig.ClientSecret) -+ } - form.Set(oauth2const.RequestParamRedirectURI, oAuthClientConfig.RedirectURI) - form.Set(oauth2const.RequestParamGrantType, string(providers.GrantTypeAuthorizationCode)) - form.Set(oauth2const.RequestParamCode, code) - -- httpReq, err := http.NewRequest(http.MethodPost, oAuthClientConfig.OAuthEndpoints.TokenEndpoint, -+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, oAuthClientConfig.OAuthEndpoints.TokenEndpoint, - strings.NewReader(form.Encode())) - if err != nil { - logger.Error(ctx, "Failed to create token request", log.Error(err)) -@@ -117,14 +140,13 @@ - }() - - if resp.StatusCode != http.StatusOK { -- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - logger.Error(ctx, "Token endpoint returned an error response", -- log.Int("statusCode", resp.StatusCode), log.String("response", string(body))) -+ log.Int("statusCode", resp.StatusCode)) - return nil, &tidcommon.InternalServerError - } - - var tokenResp TokenResponse -- if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { -+ if err := json.NewDecoder(io.LimitReader(resp.Body, 256*1024)).Decode(&tokenResp); err != nil { - logger.Error(ctx, "Failed to parse token response", log.Error(err)) - return nil, &tidcommon.InternalServerError - } -@@ -135,7 +157,7 @@ - // buildUserInfoRequest constructs the HTTP request to fetch user information from the identity provider. - func buildUserInfoRequest(ctx context.Context, userInfoEndpoint string, accessToken string, logger *log.Logger) ( - *http.Request, *tidcommon.ServiceError) { -- req, err := http.NewRequest(http.MethodGet, userInfoEndpoint, nil) -+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, userInfoEndpoint, nil) - if err != nil { - logger.Error(ctx, "Failed to create userinfo request", log.Error(err)) - return nil, &tidcommon.InternalServerError -@@ -163,9 +185,8 @@ - }() - - if resp.StatusCode != http.StatusOK { -- body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - logger.Error(ctx, "Userinfo endpoint returned an error response", -- log.Int("statusCode", resp.StatusCode), log.String("response", string(body))) -+ log.Int("statusCode", resp.StatusCode)) - return nil, &ErrorUserProfileRetrievalFailed - } - -diff --git a/backend/internal/authn/oidc/federation.go b/backend/internal/authn/oidc/federation.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/authn/oidc/federation.go -@@ -0,0 +1,158 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package oidc -+ -+import ( -+ "context" -+ "crypto/rand" -+ "crypto/sha256" -+ "encoding/base64" -+ "encoding/json" -+ "errors" -+ "io" -+ "net/http" -+ "net/url" -+ "strings" -+ "time" -+ -+ authnoauth "github.com/thunder-id/thunderid/internal/authn/oauth" -+ "github.com/thunder-id/thunderid/internal/system/jose/jwt" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+) -+ -+const maxFederationResponse = 256 * 1024 -+ -+type federationVerifierKey struct{} -+ -+func bindPKCE(authorize string, metadata map[string]string, c *authnoauth.OAuthClientConfig) (string, error) { -+ bytes := make([]byte, 32) -+ if _, err := rand.Read(bytes); err != nil { -+ return "", err -+ } -+ verifier := base64.RawURLEncoding.EncodeToString(bytes) -+ challenge := sha256.Sum256([]byte(verifier)) -+ u, err := url.Parse(authorize) -+ if err != nil { -+ return "", err -+ } -+ q := u.Query() -+ q.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) -+ q.Set("code_challenge_method", "S256") -+ requested := map[string]interface{}{} -+ for _, claim := range c.RequiredClaims { -+ requested[claim] = map[string]bool{"essential": true} -+ } -+ claims, err := json.Marshal(map[string]interface{}{"userinfo": requested}) -+ if err != nil { -+ return "", err -+ } -+ q.Set("claims", string(claims)) -+ u.RawQuery = q.Encode() -+ metadata["code_verifier"] = verifier -+ return u.String(), nil -+} -+ -+func readFederationResponse(ctx context.Context, endpoint, bearer, accept string) ([]byte, error) { -+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) -+ if err != nil { -+ return nil, err -+ } -+ if bearer != "" { -+ req.Header.Set("Authorization", "Bearer "+bearer) -+ } -+ req.Header.Set("Accept", accept) -+ resp, err := authnoauth.FederationHTTPClient().Do(req) -+ if err != nil { -+ return nil, errors.New("federation endpoint unavailable") -+ } -+ defer resp.Body.Close() -+ if resp.StatusCode != http.StatusOK { -+ return nil, errors.New("federation endpoint refused request") -+ } -+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxFederationResponse+1)) -+ if err != nil || len(body) > maxFederationResponse { -+ return nil, errors.New("federation response exceeds bound") -+ } -+ return body, nil -+} -+ -+// verifyFederationJWT fetches only the configured JWKS, never token header URLs. -+// The selected algorithm and key must agree before injected crypto verifies it. -+func (s *oidcAuthnService) verifyFederationJWT(ctx context.Context, token, alg string, c *authnoauth.OAuthClientConfig) (map[string]interface{}, *tidcommon.ServiceError) { -+ if len(token) > maxFederationResponse { -+ return nil, &ErrorInvalidIDToken -+ } -+ header, err := jwt.DecodeJWTHeader(token) -+ if err != nil || header["alg"] != alg || header["crit"] != nil || header["jku"] != nil || header["x5u"] != nil || header["b64"] != nil { -+ return nil, &ErrorInvalidIDToken -+ } -+ kid, ok := header["kid"].(string) -+ if !ok || kid == "" || len(kid) > 256 { -+ return nil, &ErrorInvalidIDToken -+ } -+ body, err := readFederationResponse(ctx, c.OAuthEndpoints.JwksEndpoint, "", "application/json") -+ if err != nil { -+ return nil, &ErrorInvalidIDToken -+ } -+ var set struct { -+ Keys []map[string]interface{} `json:"keys"` -+ } -+ if json.Unmarshal(body, &set) != nil || len(set.Keys) == 0 || len(set.Keys) > 32 { -+ return nil, &ErrorInvalidIDToken -+ } -+ var selected map[string]interface{} -+ for _, key := range set.Keys { -+ if key["kid"] != kid { -+ continue -+ } -+ if selected != nil || key["d"] != nil || (key["alg"] != nil && key["alg"] != alg) || (key["use"] != nil && key["use"] != "sig") { -+ return nil, &ErrorInvalidIDToken -+ } -+ if (strings.HasPrefix(alg, "RS") || strings.HasPrefix(alg, "PS")) && key["kty"] != "RSA" { -+ return nil, &ErrorInvalidIDToken -+ } -+ if alg == "ES256" && (key["kty"] != "EC" || key["crv"] != "P-256") { -+ return nil, &ErrorInvalidIDToken -+ } -+ selected = key -+ } -+ if selected == nil { -+ return nil, &ErrorInvalidIDToken -+ } -+ if err := s.jwtService.VerifyJWTWithPublicKey(ctx, token, providers.KeyRef{PublicKeyJWK: selected}, c.ClientID, c.Issuer); err != nil { -+ return nil, &ErrorInvalidIDTokenSignature -+ } -+ claims, err := jwt.DecodeJWTPayload(token) -+ if err != nil { -+ return nil, &ErrorInvalidIDToken -+ } -+ sub, ok := claims["sub"].(string) -+ if !ok || sub == "" || len(sub) > 1024 { -+ return nil, &ErrorInvalidIDToken -+ } -+ // Exact single audience removes multi-audience/azp ambiguity at this boundary. -+ audOK := claims["aud"] == c.ClientID -+ if aud, ok := claims["aud"].([]interface{}); ok { -+ audOK = len(aud) == 1 && aud[0] == c.ClientID -+ } -+ if !audOK || claims["iss"] != c.Issuer { -+ return nil, &ErrorInvalidIDToken -+ } -+ if azp, exists := claims["azp"]; exists && azp != c.ClientID { -+ return nil, &ErrorInvalidIDToken -+ } -+ now := float64(time.Now().Unix()) -+ exp, ok := claims["exp"].(float64) -+ if !ok || exp <= now { -+ return nil, &ErrorInvalidIDToken -+ } -+ if iat, exists := claims["iat"]; exists { -+ value, ok := iat.(float64) -+ if !ok || value > now+30 || value >= exp { -+ return nil, &ErrorInvalidIDToken -+ } -+ } -+ return claims, nil -+} -diff --git a/backend/internal/authn/oidc/federation_test.go b/backend/internal/authn/oidc/federation_test.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/authn/oidc/federation_test.go -@@ -0,0 +1,293 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package oidc -+ -+import ( -+ "context" -+ "crypto" -+ "crypto/rand" -+ "crypto/rsa" -+ "crypto/sha256" -+ "crypto/x509" -+ "encoding/base64" -+ "encoding/json" -+ "fmt" -+ "math/big" -+ "net/http" -+ "net/http/httptest" -+ "net/url" -+ "strings" -+ "testing" -+ "time" -+ -+ "github.com/stretchr/testify/mock" -+ "github.com/stretchr/testify/require" -+ authncm "github.com/thunder-id/thunderid/internal/authn/common" -+ "github.com/thunder-id/thunderid/internal/authn/oauth" -+ "github.com/thunder-id/thunderid/internal/idp" -+ "github.com/thunder-id/thunderid/internal/system/cmodels" -+ "github.com/thunder-id/thunderid/internal/system/config" -+ joseconfig "github.com/thunder-id/thunderid/internal/system/jose/config" -+ "github.com/thunder-id/thunderid/internal/system/jose/jwt" -+ "github.com/thunder-id/thunderid/internal/system/kmprovider/defaultkm" -+ "github.com/thunder-id/thunderid/internal/system/kmprovider/defaultkm/pki" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+ "github.com/thunder-id/thunderid/tests/mocks/idp/idpmock" -+) -+ -+type protocolPKI struct { -+ pki.PKIServiceInterface -+ key *rsa.PrivateKey -+} -+ -+func (p protocolPKI) GetPrivateKey(context.Context, string) (crypto.PrivateKey, *tidcommon.ServiceError) { -+ return p.key, nil -+} -+func (p protocolPKI) GetCertificateChain(string) [][]byte { return nil } -+func (p protocolPKI) GetCertThumbprint(string) string { return "issuer-key" } -+func (p protocolPKI) GetAllX509Certificates(context.Context) (map[string]*x509.Certificate, *tidcommon.ServiceError) { -+ return map[string]*x509.Certificate{"issuer-key": {PublicKey: &p.key.PublicKey}}, nil -+} -+func (p protocolPKI) GetX509Certificate(context.Context, string) (*x509.Certificate, *tidcommon.ServiceError) { -+ return &x509.Certificate{PublicKey: &p.key.PublicKey}, nil -+} -+func (p protocolPKI) GetSupportedSigningAlgorithms() []string { return []string{"RS256", "PS256"} } -+ -+func protocolJWK(key *rsa.PrivateKey) map[string]interface{} { -+ return map[string]interface{}{"kty": "RSA", "kid": "provider-key", "alg": "PS256", "use": "sig", -+ "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes())} -+} -+func protocolSign(t *testing.T, key *rsa.PrivateKey, claims map[string]interface{}, alg, kid string) string { -+ t.Helper() -+ h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid}) -+ p, _ := json.Marshal(claims) -+ input := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) -+ digest := sha256.Sum256([]byte(input)) -+ sig, err := rsa.SignPSS(rand.Reader, key, crypto.SHA256, digest[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) -+ require.NoError(t, err) -+ return input + "." + base64.RawURLEncoding.EncodeToString(sig) -+} -+ -+// TestFederationProtocol uses real local authorization, token, JWKS and UserInfo -+// endpoints and the production OAuth/JWT/default-key-manager implementations. -+// All keys and subjects are generated synthetic fixture state, never logged. -+func TestFederationProtocol(t *testing.T) { -+ config.ResetServerRuntime() -+ require.NoError(t, config.InitializeServerRuntime("", &config.Config{})) -+ issuerKey, err := rsa.GenerateKey(rand.Reader, 2048) -+ require.NoError(t, err) -+ providerKey, err := rsa.GenerateKey(rand.Reader, 2048) -+ require.NoError(t, err) -+ wrongKey, err := rsa.GenerateKey(rand.Reader, 2048) -+ require.NoError(t, err) -+ cryptoSvc := defaultkm.NewRuntimeCryptoService(protocolPKI{key: issuerKey}, nil) -+ jwtSvc, err := jwt.Initialize(cryptoSvc, joseconfig.Config{Issuer: "urn:fixture:issuer", ValidityPeriod: 300, PreferredKeyID: "issuer-key"}) -+ require.NoError(t, err) -+ mode := "" -+ nonce := "" -+ challenge := "" -+ code := "" -+ used := false -+ counter := 0 -+ issuer := "" -+ seenJTIs := map[string]bool{} -+ tokenCalls := 0 -+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -+ now := time.Now().Unix() -+ claims := map[string]interface{}{"iss": issuer, "aud": "outbound-client", "sub": "pairwise-citizen", "iat": now, "exp": now + 300, "nonce": nonce} -+ switch r.URL.Path { -+ case "/authorize": -+ q := r.URL.Query() -+ nonce = q.Get("nonce") -+ challenge = q.Get("code_challenge") -+ if q.Get("client_id") != "outbound-client" || q.Get("response_type") != "code" || q.Get("state") == "" || nonce == "" || q.Get("code_challenge_method") != "S256" || len(challenge) != 43 || q.Get("code_verifier") != "" { -+ t.Error("authorization request omitted or disclosed transaction binding") -+ w.WriteHeader(400) -+ return -+ } -+ var requested map[string]map[string]map[string]bool -+ if json.Unmarshal([]byte(q.Get("claims")), &requested) != nil || !requested["userinfo"]["person_reference"]["essential"] { -+ t.Error("essential provider-neutral claim not requested") -+ w.WriteHeader(400) -+ return -+ } -+ counter++ -+ code = fmt.Sprintf("code-%d", counter) -+ used = false -+ _ = json.NewEncoder(w).Encode(map[string]string{"code": code}) -+ case "/token": -+ tokenCalls++ -+ if mode == "token-redirect" { -+ http.Redirect(w, r, issuer+"/never", 302) -+ return -+ } -+ if r.ParseForm() != nil { -+ w.WriteHeader(400) -+ return -+ } -+ form := r.PostForm -+ d := sha256.Sum256([]byte(form.Get("code_verifier"))) -+ if used || form.Get("code") != code || base64.RawURLEncoding.EncodeToString(d[:]) != challenge { -+ w.WriteHeader(400) -+ return -+ } -+ assertion := form.Get("client_assertion") -+ parts := strings.Split(assertion, ".") -+ if len(parts) != 3 { -+ t.Error("client assertion missing") -+ w.WriteHeader(400) -+ return -+ } -+ signature, e := base64.RawURLEncoding.DecodeString(parts[2]) -+ digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) -+ if e != nil || rsa.VerifyPKCS1v15(&issuerKey.PublicKey, crypto.SHA256, digest[:], signature) != nil { -+ t.Error("client assertion signature rejected") -+ w.WriteHeader(400) -+ return -+ } -+ payload, e := jwt.DecodeJWTPayload(assertion) -+ header, he := jwt.DecodeJWTHeader(assertion) -+ jti, _ := payload["jti"].(string) -+ if e != nil || he != nil || header["alg"] != "RS256" || header["kid"] != "issuer-key" || payload["iss"] != "outbound-client" || payload["sub"] != "outbound-client" || payload["aud"] != issuer+"/token" || jti == "" || seenJTIs[jti] || payload["exp"].(float64)-payload["iat"].(float64) != 60 || form.Get("client_secret") != "" || form.Get("client_assertion_type") != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { -+ t.Error("client assertion contract rejected") -+ w.WriteHeader(400) -+ return -+ } -+ seenJTIs[jti] = true -+ used = true -+ key := providerKey -+ alg := "PS256" -+ kid := "provider-key" -+ switch mode { -+ case "id-wrong-issuer": -+ claims["iss"] = "urn:other" -+ case "id-wrong-audience": -+ claims["aud"] = "other-client" -+ case "id-multi-audience": -+ claims["aud"] = []string{"outbound-client", "other-client"} -+ case "id-wrong-azp": -+ claims["azp"] = "other-client" -+ case "id-no-sub": -+ delete(claims, "sub") -+ case "id-expired": -+ claims["exp"] = now - 1 -+ case "id-future": -+ claims["iat"] = now + 120 -+ case "id-wrong-nonce": -+ claims["nonce"] = "other-transaction" -+ case "id-wrong-key": -+ key = wrongKey -+ case "id-wrong-alg": -+ alg = "RS256" -+ case "id-unknown-kid": -+ kid = "unknown" -+ } -+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"access_token": "fixture-access", "token_type": "Bearer", "id_token": protocolSign(t, key, claims, alg, kid), "expires_in": 300}) -+ case "/jwks": -+ if mode == "jwks-redirect" { -+ http.Redirect(w, r, issuer+"/never", 302) -+ return -+ } -+ keys := []map[string]interface{}{protocolJWK(providerKey)} -+ if mode == "duplicate-kid" { -+ keys = append(keys, protocolJWK(wrongKey)) -+ } -+ if mode == "jwks-wrong-alg" { -+ keys[0]["alg"] = "RS256" -+ } -+ _ = json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) -+ case "/userinfo": -+ if r.Header.Get("Authorization") != "Bearer fixture-access" { -+ t.Error("userinfo access bearer missing") -+ w.WriteHeader(401) -+ return -+ } -+ if mode == "userinfo-redirect" { -+ http.Redirect(w, r, issuer+"/never", 302) -+ return -+ } -+ if mode == "userinfo-unavailable" { -+ w.WriteHeader(503) -+ return -+ } -+ claims["person_reference"] = "synthetic-domain-reference" -+ key := providerKey -+ switch mode { -+ case "userinfo-wrong-issuer": -+ claims["iss"] = "urn:other" -+ case "userinfo-wrong-audience": -+ claims["aud"] = "other-client" -+ case "userinfo-wrong-sub": -+ claims["sub"] = "other-citizen" -+ case "userinfo-missing-sub": -+ delete(claims, "sub") -+ case "userinfo-missing-essential": -+ delete(claims, "person_reference") -+ case "userinfo-wrong-key": -+ key = wrongKey -+ case "userinfo-unsigned": -+ _ = json.NewEncoder(w).Encode(claims) -+ return -+ } -+ _, _ = w.Write([]byte(protocolSign(t, key, claims, "PS256", "provider-key"))) -+ default: -+ t.Error("unexpected endpoint or followed redirect") -+ w.WriteHeader(400) -+ } -+ })) -+ defer server.Close() -+ issuer = server.URL -+ properties := map[string]string{idp.PropClientID: "outbound-client", idp.PropTokenEndpointAuthMethod: "private_key_jwt", idp.PropIDTokenSigningAlg: "PS256", idp.PropUserInfoSigningAlg: "PS256", idp.PropRequiredClaims: "person_reference", idp.PropIssuer: issuer, idp.PropRedirectURI: "http://127.0.0.1:9999/callback", idp.PropScopes: "openid", idp.PropAuthorizationEndpoint: issuer + "/authorize", idp.PropTokenEndpoint: issuer + "/token", idp.PropUserInfoEndpoint: issuer + "/userinfo", idp.PropJwksEndpoint: issuer + "/jwks"} -+ dto := &providers.IDPDTO{ID: "fixture-connection", Type: providers.IDPTypeOIDC} -+ for name, value := range properties { -+ prop, e := cmodels.NewProperty(name, value, false) -+ require.NoError(t, e) -+ dto.Properties = append(dto.Properties, *prop) -+ } -+ idpSvc := idpmock.NewIDPServiceInterfaceMock(t) -+ idpSvc.On("GetIdentityProvider", mock.Anything, "fixture-connection").Return(dto, nil) -+ svc := newOIDCAuthnService(oauth.Initialize(idpSvc, nil), jwtSvc) -+ for _, scenario := range []string{"ok", "id-wrong-issuer", "id-wrong-audience", "id-multi-audience", "id-wrong-azp", "id-no-sub", "id-expired", "id-future", "id-wrong-nonce", "id-wrong-key", "id-wrong-alg", "id-unknown-kid", "duplicate-kid", "jwks-wrong-alg", "jwks-redirect", "token-redirect", "userinfo-wrong-issuer", "userinfo-wrong-audience", "userinfo-wrong-sub", "userinfo-missing-sub", "userinfo-missing-essential", "userinfo-wrong-key", "userinfo-unsigned", "userinfo-unavailable", "userinfo-redirect", "bad-verifier", "missing-verifier", "ok-after-refusals"} { -+ t.Run(scenario, func(t *testing.T) { -+ mode = scenario -+ authorize, metadata, svcErr := svc.BuildAuthorizeURL(context.Background(), "fixture-connection") -+ require.Nil(t, svcErr) -+ resp, e := http.Get(authorize) -+ require.NoError(t, e) -+ require.Equal(t, 200, resp.StatusCode) -+ resp.Body.Close() -+ verifier := metadata["code_verifier"] -+ if mode == "bad-verifier" { -+ verifier = strings.Repeat("a", 43) -+ } -+ if mode == "missing-verifier" { -+ verifier = "" -+ } -+ authz := authncm.AuthorizationData{Code: code, Nonce: metadata["nonce"], CodeVerifier: verifier} -+ result, svcErr := svc.Authenticate(context.Background(), "fixture-connection", authz) -+ if strings.HasPrefix(mode, "ok") { -+ require.Nil(t, svcErr) -+ require.NotNil(t, result) -+ require.Equal(t, map[string]interface{}{"sub": "pairwise-citizen", "registry_federation_issuer": issuer}, result.Token) -+ require.Equal(t, issuer, result.AuthenticatedClaims["registry_federation_issuer"]) -+ require.Equal(t, "synthetic-domain-reference", result.AuthenticatedClaims["person_reference"]) -+ replay, err := svc.Authenticate(context.Background(), "fixture-connection", authz) -+ require.Nil(t, replay) -+ require.NotNil(t, err) -+ } else { -+ require.Nil(t, result) -+ require.NotNil(t, svcErr) -+ } -+ }) -+ } -+ require.Greater(t, tokenCalls, 20) -+ require.Greater(t, len(seenJTIs), 20) -+ c, e := svc.GetOAuthClientConfig(context.Background(), "fixture-connection") -+ require.Nil(t, e) -+ c.OAuthEndpoints.JwksEndpoint = "https://other.example/jwks" -+ require.Error(t, c.ValidateFederation()) -+ _, err = url.Parse(issuer) -+ require.NoError(t, err) -+} -diff --git a/backend/internal/authn/oidc/service.go b/backend/internal/authn/oidc/service.go ---- a/backend/internal/authn/oidc/service.go -+++ b/backend/internal/authn/oidc/service.go -@@ -76,6 +76,17 @@ - } - metadata[oauth2const.RequestParamNonce] = nonce - -+ config, configErr := s.GetOAuthClientConfig(ctx, idpID) -+ if configErr != nil { -+ return "", nil, configErr -+ } -+ if config.TokenEndpointAuthMethod == "private_key_jwt" { -+ var err error -+ authorizeURL, err = bindPKCE(authorizeURL, metadata, config) -+ if err != nil { -+ return "", nil, &tidcommon.InternalServerError -+ } -+ } - return authorizeURL, metadata, nil - } - -@@ -83,6 +94,22 @@ - // and validates the token response if validateResponse is true. - func (s *oidcAuthnService) ExchangeCodeForToken(ctx context.Context, idpID, code string, validateResponse bool) ( - *authnoauth.TokenResponse, *tidcommon.ServiceError) { -+ config, configErr := s.GetOAuthClientConfig(ctx, idpID) -+ if configErr != nil { -+ return nil, configErr -+ } -+ if config.TokenEndpointAuthMethod == "private_key_jwt" { -+ verifier, _ := ctx.Value(federationVerifierKey{}).(string) -+ if len(verifier) != 43 { -+ return nil, &ErrorInvalidIDToken -+ } -+ assertion, _, err := s.jwtService.GenerateJWT(ctx, config.ClientID, config.ClientID, 60, -+ map[string]interface{}{"aud": config.OAuthEndpoints.TokenEndpoint}, "JWT", "RS256") -+ if err != nil { -+ return nil, err -+ } -+ ctx = authnoauth.WithFederationExchange(ctx, verifier, assertion) -+ } - tokenResp, svcErr := s.internal.ExchangeCodeForToken(ctx, idpID, code, false) - if svcErr != nil { - return nil, svcErr -@@ -147,6 +174,10 @@ - return svcErr - } - -+ if oAuthClientConfig.TokenEndpointAuthMethod == "private_key_jwt" { -+ _, err := s.verifyFederationJWT(ctx, idToken, oAuthClientConfig.IDTokenSigningAlg, oAuthClientConfig) -+ return err -+ } - // Validate ID token signature using JWKS endpoint if available - if oAuthClientConfig.OAuthEndpoints.JwksEndpoint != "" { - err := s.jwtService.VerifyJWTWithJWKS(ctx, idToken, oAuthClientConfig.OAuthEndpoints.JwksEndpoint, "", "") -@@ -189,6 +220,17 @@ - // FetchUserInfo retrieves user information from the external identity provider. - func (s *oidcAuthnService) FetchUserInfo(ctx context.Context, idpID, accessToken string) ( - map[string]interface{}, *tidcommon.ServiceError) { -+ config, err := s.GetOAuthClientConfig(ctx, idpID) -+ if err != nil { -+ return nil, err -+ } -+ if config.TokenEndpointAuthMethod == "private_key_jwt" { -+ body, err := readFederationResponse(ctx, config.OAuthEndpoints.UserInfoEndpoint, accessToken, "application/jwt") -+ if err != nil { -+ return nil, &ErrorInvalidIDToken -+ } -+ return s.verifyFederationJWT(ctx, string(body), config.UserInfoSigningAlg, config) -+ } - return s.internal.FetchUserInfo(ctx, idpID, accessToken) - } - -@@ -200,6 +242,7 @@ - logger := s.logger.With(log.String("idpId", idpID)) - logger.Debug(ctx, "Performing federated OIDC authentication") - -+ ctx = context.WithValue(ctx, federationVerifierKey{}, authzData.CodeVerifier) - tokenResp, svcErr := s.ExchangeCodeForToken(ctx, idpID, authzData.Code, true) - if svcErr != nil { - return nil, svcErr -@@ -231,7 +274,30 @@ - // Fetch user info if a UserInfo endpoint and additional scopes are configured, so callers get the - // full attribute set. The identity itself comes from the ID token, so a failed fetch is not fatal. - oauthConfig, svcErr := s.GetOAuthClientConfig(ctx, idpID) -- if svcErr == nil && oauthConfig.OAuthEndpoints.UserInfoEndpoint != "" && len(oauthConfig.Scopes) > 1 { -+ if svcErr != nil { -+ return nil, svcErr -+ } -+ if oauthConfig.TokenEndpointAuthMethod == "private_key_jwt" { -+ userInfo, err := s.FetchUserInfo(ctx, idpID, tokenResp.AccessToken) -+ if err != nil { -+ return nil, err -+ } -+ if userInfo["sub"] != sub { -+ return nil, &ErrorInvalidIDToken -+ } -+ for _, claim := range oauthConfig.RequiredClaims { -+ value, ok := userInfo[claim].(string) -+ if !ok || strings.TrimSpace(value) == "" || len(value) > 4096 { -+ return nil, &ErrorInvalidIDToken -+ } -+ claims[claim] = value -+ } -+ for k, v := range userInfo { -+ if _, exists := claims[k]; !exists { -+ claims[k] = v -+ } -+ } -+ } else if oauthConfig.OAuthEndpoints.UserInfoEndpoint != "" && len(oauthConfig.Scopes) > 1 { - userInfo, infoErr := s.FetchUserInfo(ctx, idpID, tokenResp.AccessToken) - if infoErr == nil { - if userInfoSub, ok := userInfo["sub"].(string); !ok || userInfoSub == sub { -@@ -246,7 +312,21 @@ - } - } - -- return s.internal.BuildFederatedAuthResult(ctx, idpID, sub, claims) -+ result, resultErr := s.internal.BuildFederatedAuthResult(ctx, idpID, sub, claims) -+ if resultErr != nil { -+ return nil, resultErr -+ } -+ if oauthConfig.TokenEndpointAuthMethod == "private_key_jwt" { -+ // A pairwise subject is scoped to its verified issuer. Disable account -+ // linking by mutable claims on this governed citizen federation path. -+ result.Token = map[string]interface{}{"sub": sub, "registry_federation_issuer": oauthConfig.Issuer} -+ if result.AuthenticatedClaims == nil { -+ result.AuthenticatedClaims = make(map[string]interface{}) -+ } -+ result.AuthenticatedClaims["sub"] = sub -+ result.AuthenticatedClaims["registry_federation_issuer"] = oauthConfig.Issuer -+ } -+ return result, nil - } - - // BuildFederatedAuthResult delegates to the underlying OAuth service, which applies attribute mapping -diff --git a/backend/internal/authn/oidc/service_test.go b/backend/internal/authn/oidc/service_test.go ---- a/backend/internal/authn/oidc/service_test.go -+++ b/backend/internal/authn/oidc/service_test.go -@@ -92,6 +92,8 @@ - } - - func (suite *OIDCAuthnServiceTestSuite) TestBuildAuthorizeURLSuccess() { -+ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) -+ - expectedURL := "https://example.com/authorize?client_id=test" - suite.mockOAuthService.On("BuildAuthorizeURL", mock.Anything, testOIDCIDPID). - Return(expectedURL, map[string]string{oauth2const.RequestParamState: "test-state"}, nil) -@@ -104,6 +106,7 @@ - } - - func (suite *OIDCAuthnServiceTestSuite) TestBuildAuthorizeURLError() { -+ - svcErr := &tidcommon.ServiceError{ - Code: "ERROR", - ErrorDescription: tidcommon.I18nMessage{ -@@ -151,6 +154,7 @@ - name: "WithoutValidation", - validateResponse: false, - setupMocks: func() { -+ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) - code := "auth_code" - tokenResp := &oauth.TokenResponse{ - AccessToken: "access_token", -@@ -336,6 +340,8 @@ - } - - func (suite *OIDCAuthnServiceTestSuite) TestFetchUserInfoSuccess() { -+ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) -+ - accessToken := "access_token" - userInfo := map[string]interface{}{ - "sub": "user123", -@@ -350,6 +356,8 @@ - } - - func (suite *OIDCAuthnServiceTestSuite) TestExchangeCodeForTokenInternalError() { -+ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) -+ - suite.mockOAuthService.On("ExchangeCodeForToken", mock.Anything, testOIDCIDPID, "auth_code", false). - Return(nil, &tidcommon.ServiceError{Code: "INT-ERR"}) - -@@ -599,6 +607,7 @@ - } - - func (suite *OIDCAuthnServiceTestSuite) TestAuthenticateExchangeCodeError() { -+ - suite.mockOAuthService = oauthmock.NewOAuthAuthnServiceInterfaceMock(suite.T()) - suite.mockJWTService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) - -@@ -607,6 +616,7 @@ - suite.True(ok) - suite.service = *cast - -+ suite.mockOAuthService.On("GetOAuthClientConfig", mock.Anything, testOIDCIDPID).Return(&oauth.OAuthClientConfig{}, nil) - suite.mockOAuthService.On("ExchangeCodeForToken", mock.Anything, testOIDCIDPID, "bad_code", false). - Return(nil, &tidcommon.ServiceError{Code: "TOKEN-ERR"}) - -diff --git a/backend/internal/connection/connection_declarative_model.go b/backend/internal/connection/connection_declarative_model.go ---- a/backend/internal/connection/connection_declarative_model.go -+++ b/backend/internal/connection/connection_declarative_model.go -@@ -19,18 +19,22 @@ - Description string `yaml:"description,omitempty" json:"description,omitempty"` - - // IdP-backed vendor fields (google, github, oidc, oauth). -- ClientID string `yaml:"clientId,omitempty" json:"clientId,omitempty"` -- ClientSecret string `yaml:"clientSecret,omitempty" json:"clientSecret,omitempty"` -- RedirectURI string `yaml:"redirectUri,omitempty" json:"redirectUri,omitempty"` -- Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` -- Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` -- AuthorizationEndpoint string `yaml:"authorizationEndpoint,omitempty" json:"authorizationEndpoint,omitempty"` -- TokenEndpoint string `yaml:"tokenEndpoint,omitempty" json:"tokenEndpoint,omitempty"` -- UserInfoEndpoint string `yaml:"userInfoEndpoint,omitempty" json:"userInfoEndpoint,omitempty"` -- JwksEndpoint string `yaml:"jwksEndpoint,omitempty" json:"jwksEndpoint,omitempty"` -- Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` -- TokenExchangeEnabled *bool `yaml:"tokenExchangeEnabled,omitempty" json:"tokenExchangeEnabled,omitempty"` -- TrustedTokenAudience string `yaml:"trustedTokenAudience,omitempty" json:"trustedTokenAudience,omitempty"` -+ TokenEndpointAuthMethod string `yaml:"tokenEndpointAuthMethod,omitempty" json:"tokenEndpointAuthMethod,omitempty"` -+ IDTokenSigningAlg string `yaml:"idTokenSigningAlg,omitempty" json:"idTokenSigningAlg,omitempty"` -+ UserInfoSigningAlg string `yaml:"userInfoSigningAlg,omitempty" json:"userInfoSigningAlg,omitempty"` -+ RequiredClaims []string `yaml:"requiredClaims,omitempty" json:"requiredClaims,omitempty"` -+ ClientID string `yaml:"clientId,omitempty" json:"clientId,omitempty"` -+ ClientSecret string `yaml:"clientSecret,omitempty" json:"clientSecret,omitempty"` -+ RedirectURI string `yaml:"redirectUri,omitempty" json:"redirectUri,omitempty"` -+ Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"` -+ Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` -+ AuthorizationEndpoint string `yaml:"authorizationEndpoint,omitempty" json:"authorizationEndpoint,omitempty"` -+ TokenEndpoint string `yaml:"tokenEndpoint,omitempty" json:"tokenEndpoint,omitempty"` -+ UserInfoEndpoint string `yaml:"userInfoEndpoint,omitempty" json:"userInfoEndpoint,omitempty"` -+ JwksEndpoint string `yaml:"jwksEndpoint,omitempty" json:"jwksEndpoint,omitempty"` -+ Issuer string `yaml:"issuer,omitempty" json:"issuer,omitempty"` -+ TokenExchangeEnabled *bool `yaml:"tokenExchangeEnabled,omitempty" json:"tokenExchangeEnabled,omitempty"` -+ TrustedTokenAudience string `yaml:"trustedTokenAudience,omitempty" json:"trustedTokenAudience,omitempty"` - - //nolint:lll // long struct tag: both yaml and json keys needed for declarative load/export and import - AttributeConfiguration *providers.AttributeConfiguration `yaml:"attributeConfiguration,omitempty" json:"attributeConfiguration,omitempty"` -diff --git a/backend/internal/connection/declarative_resource.go b/backend/internal/connection/declarative_resource.go ---- a/backend/internal/connection/declarative_resource.go -+++ b/backend/internal/connection/declarative_resource.go -@@ -218,11 +218,16 @@ - } - - model := connectionExportModel{ -- ID: dto.ID, -- Type: vendor, -- Name: dto.Name, -- Description: dto.Description, -- ClientID: values[idp.PropClientID], -+ ID: dto.ID, -+ Type: vendor, -+ Name: dto.Name, -+ Description: dto.Description, -+ ClientID: values[idp.PropClientID], -+ TokenEndpointAuthMethod: values[idp.PropTokenEndpointAuthMethod], -+ IDTokenSigningAlg: values[idp.PropIDTokenSigningAlg], -+ UserInfoSigningAlg: values[idp.PropUserInfoSigningAlg], -+ RequiredClaims: splitScopes(values[idp.PropRequiredClaims]), -+ - ClientSecret: values[idp.PropClientSecret], - RedirectURI: values[idp.PropRedirectURI], - Scopes: splitScopes(values[idp.PropScopes]), -@@ -309,6 +314,11 @@ - return dto, nil, nil - case "oidc": - dto, err := oidcToIDPDTO(oidcConnectionRequest{ -+ TokenEndpointAuthMethod: model.TokenEndpointAuthMethod, -+ IDTokenSigningAlg: model.IDTokenSigningAlg, -+ UserInfoSigningAlg: model.UserInfoSigningAlg, -+ RequiredClaims: model.RequiredClaims, -+ - Name: model.Name, Description: model.Description, ClientID: model.ClientID, - ClientSecret: model.ClientSecret, RedirectURI: model.RedirectURI, - AuthorizationEndpoint: model.AuthorizationEndpoint, TokenEndpoint: model.TokenEndpoint, -diff --git a/backend/internal/connection/oidc.go b/backend/internal/connection/oidc.go ---- a/backend/internal/connection/oidc.go -+++ b/backend/internal/connection/oidc.go -@@ -13,44 +13,52 @@ - - // oidcConnectionRequest is the create/update payload for a generic OpenID Connect connection. - type oidcConnectionRequest struct { -- Name string `json:"name"` -- Description string `json:"description,omitempty"` -- ClientID string `json:"clientId"` -- ClientSecret string `json:"clientSecret"` -- RedirectURI string `json:"redirectUri"` -- AuthorizationEndpoint string `json:"authorizationEndpoint"` -- TokenEndpoint string `json:"tokenEndpoint"` -- UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` -- JwksEndpoint string `json:"jwksEndpoint,omitempty"` -- Issuer string `json:"issuer,omitempty"` -- Scopes []string `json:"scopes,omitempty"` -- Prompt string `json:"prompt,omitempty"` -- TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` -- TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` -- IDJagEnabled *bool `json:"idJagEnabled,omitempty"` -+ Name string `json:"name"` -+ Description string `json:"description,omitempty"` -+ TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` -+ IDTokenSigningAlg string `json:"idTokenSigningAlg,omitempty"` -+ UserInfoSigningAlg string `json:"userInfoSigningAlg,omitempty"` -+ RequiredClaims []string `json:"requiredClaims,omitempty"` -+ ClientID string `json:"clientId"` -+ ClientSecret string `json:"clientSecret"` -+ RedirectURI string `json:"redirectUri"` -+ AuthorizationEndpoint string `json:"authorizationEndpoint"` -+ TokenEndpoint string `json:"tokenEndpoint"` -+ UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` -+ JwksEndpoint string `json:"jwksEndpoint,omitempty"` -+ Issuer string `json:"issuer,omitempty"` -+ Scopes []string `json:"scopes,omitempty"` -+ Prompt string `json:"prompt,omitempty"` -+ TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` -+ TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` -+ IDJagEnabled *bool `json:"idJagEnabled,omitempty"` - - AttributeConfiguration *providers.AttributeConfiguration `json:"attributeConfiguration,omitempty"` - } - - // oidcConnectionResponse is the detail payload for an OIDC connection (secret masked). - type oidcConnectionResponse struct { -- ID string `json:"id"` -- Name string `json:"name"` -- Description string `json:"description,omitempty"` -- Type string `json:"type"` -- ClientID string `json:"clientId,omitempty"` -- ClientSecret string `json:"clientSecret,omitempty"` -- RedirectURI string `json:"redirectUri,omitempty"` -- AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` -- TokenEndpoint string `json:"tokenEndpoint,omitempty"` -- UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` -- JwksEndpoint string `json:"jwksEndpoint,omitempty"` -- Issuer string `json:"issuer,omitempty"` -- Scopes []string `json:"scopes,omitempty"` -- Prompt string `json:"prompt,omitempty"` -- TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` -- TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` -- IDJagEnabled *bool `json:"idJagEnabled,omitempty"` -+ ID string `json:"id"` -+ Name string `json:"name"` -+ Description string `json:"description,omitempty"` -+ Type string `json:"type"` -+ TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` -+ IDTokenSigningAlg string `json:"idTokenSigningAlg,omitempty"` -+ UserInfoSigningAlg string `json:"userInfoSigningAlg,omitempty"` -+ RequiredClaims []string `json:"requiredClaims,omitempty"` -+ ClientID string `json:"clientId,omitempty"` -+ ClientSecret string `json:"clientSecret,omitempty"` -+ RedirectURI string `json:"redirectUri,omitempty"` -+ AuthorizationEndpoint string `json:"authorizationEndpoint,omitempty"` -+ TokenEndpoint string `json:"tokenEndpoint,omitempty"` -+ UserInfoEndpoint string `json:"userInfoEndpoint,omitempty"` -+ JwksEndpoint string `json:"jwksEndpoint,omitempty"` -+ Issuer string `json:"issuer,omitempty"` -+ Scopes []string `json:"scopes,omitempty"` -+ Prompt string `json:"prompt,omitempty"` -+ TokenExchangeEnabled *bool `json:"tokenExchangeEnabled,omitempty"` -+ TrustedTokenAudience string `json:"trustedTokenAudience,omitempty"` -+ IDJagEnabled *bool `json:"idJagEnabled,omitempty"` - - AttributeConfiguration *providers.AttributeConfiguration `json:"attributeConfiguration,omitempty"` - } -@@ -64,6 +72,11 @@ - isSecret bool - }{ - {idp.PropClientID, req.ClientID, false}, -+ {idp.PropTokenEndpointAuthMethod, req.TokenEndpointAuthMethod, false}, -+ {idp.PropIDTokenSigningAlg, req.IDTokenSigningAlg, false}, -+ {idp.PropUserInfoSigningAlg, req.UserInfoSigningAlg, false}, -+ {idp.PropRequiredClaims, joinScopes(req.RequiredClaims), false}, -+ - {idp.PropClientSecret, req.ClientSecret, true}, - {idp.PropRedirectURI, req.RedirectURI, false}, - {idp.PropAuthorizationEndpoint, req.AuthorizationEndpoint, false}, -@@ -115,11 +128,16 @@ - return oidcConnectionResponse{}, err - } - resp := oidcConnectionResponse{ -- ID: dto.ID, -- Name: dto.Name, -- Description: dto.Description, -- Type: connectionTypeName(dto.Type), -- ClientID: values[idp.PropClientID], -+ ID: dto.ID, -+ Name: dto.Name, -+ Description: dto.Description, -+ Type: connectionTypeName(dto.Type), -+ ClientID: values[idp.PropClientID], -+ TokenEndpointAuthMethod: values[idp.PropTokenEndpointAuthMethod], -+ IDTokenSigningAlg: values[idp.PropIDTokenSigningAlg], -+ UserInfoSigningAlg: values[idp.PropUserInfoSigningAlg], -+ RequiredClaims: splitScopes(values[idp.PropRequiredClaims]), -+ - ClientSecret: values[idp.PropClientSecret], - RedirectURI: values[idp.PropRedirectURI], - AuthorizationEndpoint: values[idp.PropAuthorizationEndpoint], -diff --git a/backend/internal/flow/executor/auth_assert_executor.go b/backend/internal/flow/executor/auth_assert_executor.go ---- a/backend/internal/flow/executor/auth_assert_executor.go -+++ b/backend/internal/flow/executor/auth_assert_executor.go -@@ -5,6 +5,7 @@ - - import ( - "context" -+ "encoding/json" - "errors" - "slices" - "sort" -@@ -275,6 +276,18 @@ - return "", attrErr - } - -+ // User attributes can never supply the server-only consent binding. -+ delete(resolvedAttributes, providers.DelegationConsentCacheKey) -+ delete(resolvedAttributes, providers.DelegationConsentPolicyKey) -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ var binding providers.DelegationConsentBinding -+ if json.Unmarshal([]byte(ctx.RuntimeData[providers.DelegationConsentCacheKey]), &binding) != nil || binding.AuthorizationID == "" || binding.UserID != entityRef.EntityID || binding.AppID != ctx.Application.ID { -+ return "", errors.New("fresh citizen consent binding is missing") -+ } -+ binding.Subject = tokenSub -+ encoded, _ := json.Marshal(binding) -+ resolvedAttributes[providers.DelegationConsentCacheKey] = string(encoded) -+ } - if ttlSecondsStr, exists := ctx.RuntimeData[common.RuntimeKeyUserAttributesCacheTTLSeconds]; exists { - // We are not in an App Native flow, so we need to cache the user attributes - if len(resolvedAttributes) > 0 { -diff --git a/backend/internal/flow/executor/consent_executor.go b/backend/internal/flow/executor/consent_executor.go ---- a/backend/internal/flow/executor/consent_executor.go -+++ b/backend/internal/flow/executor/consent_executor.go -@@ -4,9 +4,12 @@ - package executor - - import ( -+ "context" - "encoding/json" - "errors" -+ "fmt" - "html" -+ "reflect" - "slices" - "strconv" - "strings" -@@ -106,6 +109,11 @@ - return execResp, errors.New("Failed to get entity reference from AuthUser") - } - -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil && (entityRef == nil || entityRef.EntityCategory != string(providers.EntityCategoryUser)) { -+ execResp.Status = providers.ExecFailure -+ execResp.Error = &ErrUserNotAuthenticated -+ return execResp, nil -+ } - availableAttrs, svcErr := e.authnProvider.GetUserAvailableAttributes(ctx.Context, execResp.AuthUser) - execResp.AuthUser = authUser - if svcErr != nil { -@@ -141,6 +149,28 @@ - availableAttributes := e.buildAugmentedAvailableAttributes(availableAttrResp, entityRef) - appName := ctx.Application.Name - forceReprompt := ctx.RuntimeData[common.RuntimeKeyForceConsentReprompt] == "true" -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ if consent.Delegation.Validate() != nil || consent.ValidityPeriod <= 0 || consent.ValidityPeriod > 300 || ctx.RuntimeData[common.RuntimeKeyResourceServerIdentifier] != consent.Delegation.Resource { -+ return nil, errors.New("citizen delegation policy is invalid") -+ } -+ requested := strings.Fields(ctx.RuntimeData[common.RuntimeKeyRequestedPermissions]) -+ if len(requested) == 0 { -+ return nil, errors.New("citizen delegation requires requested permissions") -+ } -+ for _, scope := range requested { -+ if _, ok := consent.Delegation.ScopeFields[scope]; !ok { -+ return nil, errors.New("permission has no consent field description") -+ } -+ } -+ if existing, ok := ctx.RuntimeData["authorized_permissions"]; ok { -+ requested = strings.Fields(intersectPermissionSpaceList(strings.Join(requested, " "), existing)) -+ } -+ authorizedPermissions = requested -+ forceReprompt = true -+ execResp.AdditionalData["citizenConsentWithdrawal"] = "true" -+ policy, _ := json.Marshal(consent.Delegation) -+ execResp.RuntimeData[providers.DelegationConsentPolicyKey] = string(policy) -+ } - - // Resolve consent to determine if any required consents are missing and need to be prompted - promptData, svcErr := e.consentEnforcer.ResolveConsent( -@@ -166,6 +196,20 @@ - return execResp, nil - } - -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ for i := range promptData.Purposes { -+ prompt := &promptData.Purposes[i] -+ fields := []string{} -+ for _, scope := range authorizedPermissions { -+ for _, field := range consent.Delegation.ScopeFields[scope] { -+ if !slices.Contains(fields, field) { -+ fields = append(fields, field) -+ } -+ } -+ } -+ prompt.Description = fmt.Sprintf("%s may use %s for %s. Fields: %s. Access lasts at most %d seconds. A later request requires your consent again.", appName, consent.Delegation.Resource, consent.Delegation.Purpose, strings.Join(fields, ", "), consent.ValidityPeriod) -+ } -+ } - // Consent is needed — forward prompt data to the prompt node via ForwardedData - promptJSON, err := json.Marshal(promptData.Purposes) - if err != nil { -@@ -227,6 +271,29 @@ - } - - failOnDeny, _ := ctx.NodeProperties[propertyKeyConsentFailOnDeny].(bool) -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ failOnDeny = true -+ var shown providers.DelegationConsentConfig -+ if json.Unmarshal([]byte(ctx.RuntimeData[providers.DelegationConsentPolicyKey]), &shown) != nil || !reflect.DeepEqual(shown, *consent.Delegation) { -+ return nil, errors.New("citizen consent policy changed during prompt") -+ } -+ } -+ -+ if decisions.Reason == providers.ConsentDecisionReason("withdrawal") { -+ consent := ctx.Application.LoginConsent -+ withdrawer, ok := e.consentEnforcer.(interface { -+ WithdrawAuthenticatedConsent(context.Context, string, string) *tidcommon.ServiceError -+ }) -+ if consent == nil || consent.Delegation == nil || !ok { -+ return nil, errors.New("citizen withdrawal is not configured") -+ } -+ if err := withdrawer.WithdrawAuthenticatedConsent(ctx.Context, appID, userID); err != nil { -+ return nil, errors.New("citizen withdrawal could not be recorded") -+ } -+ execResp.Status = providers.ExecFailure -+ execResp.Error = &tidcommon.ServiceError{Type: tidcommon.ClientErrorType, Code: "CITIZEN-CONSENT-WITHDRAWN", Error: tidcommon.I18nMessage{DefaultValue: "Access withdrawn"}, ErrorDescription: tidcommon.I18nMessage{DefaultValue: "This agent's consent has been withdrawn. No new access was issued."}} -+ return execResp, nil -+ } - - // A timed out prompt is not a user decision, so nothing is recorded and nothing is consented. - // The expiry check below is skipped because such a submission is expected to arrive late. -@@ -255,6 +322,14 @@ - return execResp, nil - } - } -+ } -+ -+ // Declining this request does not withdraw an earlier approval. Withdrawal -+ // is an explicit, separately authenticated action above. -+ if c := ctx.Application.LoginConsent; c != nil && c.Delegation != nil && !decisions.Approved { -+ execResp.Status = providers.ExecFailure -+ execResp.Error = &ErrConsentDenied -+ return execResp, nil - } - - // Determine validity period from the application config -@@ -309,10 +384,30 @@ - // understand the full consent data structure. Both keys are always set (even if empty) so - // auth assert knows that the consent step ran and can apply the appropriate precedence chain. - consentedAttrs := collectApprovedElementsByPurposeName(consentRecord, buildAttributePurposeName(appID)) -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ essential, optional := e.getRequiredAttributes(ctx) -+ consentedAttrs = strings.Fields(intersectPermissionSpaceList(strings.Join(consentedAttrs, " "), strings.Join(append(essential, optional...), " "))) -+ } - execResp.RuntimeData[common.RuntimeKeyConsentedAttributes] = strings.Join(consentedAttrs, " ") - consentedPerms := collectApprovedElementsByPurposeName(consentRecord, buildPermissionsPurposeName(appID)) - execResp.RuntimeData[common.RuntimeKeyConsentedPermissions] = strings.Join(consentedPerms, " ") - -+ if consent := ctx.Application.LoginConsent; consent != nil && consent.Delegation != nil { -+ binding := providers.DelegationConsentBinding{ConsentID: consentRecord.ID, AppID: appID, UserID: userID, ValidityPeriod: consent.ValidityPeriod, Deadline: consentRecord.ValidityTime, Policy: *consent.Delegation, Scopes: consentedPerms, Attributes: consentedAttrs} -+ for _, authorization := range consentRecord.Authorizations { -+ if authorization.UserID == userID && authorization.Status == providers.AuthorizationStatusApproved { -+ binding.AuthorizationID = authorization.ID -+ } -+ } -+ if binding.AuthorizationID == "" || binding.Deadline <= time.Now().Unix() || binding.Deadline > time.Now().Unix()+300 { -+ return nil, errors.New("consent authorization binding is missing or expired") -+ } -+ // Merged native records may include earlier scopes. Retain only this attempt's prompt. -+ binding.Scopes = strings.Fields(intersectPermissionSpaceList(strings.Join(binding.Scopes, " "), ctx.RuntimeData[common.RuntimeKeyRequestedPermissions])) -+ execResp.RuntimeData[common.RuntimeKeyConsentedPermissions] = strings.Join(binding.Scopes, " ") -+ encoded, _ := json.Marshal(binding) -+ execResp.RuntimeData[providers.DelegationConsentCacheKey] = string(encoded) -+ } - logger.Debug(ctx.Context, "Consent recorded successfully", log.String("consentID", consentRecord.ID)) - execResp.Status = providers.ExecComplete - return execResp, nil -diff --git a/backend/internal/flow/executor/consent_executor_test.go b/backend/internal/flow/executor/consent_executor_test.go ---- a/backend/internal/flow/executor/consent_executor_test.go -+++ b/backend/internal/flow/executor/consent_executor_test.go -@@ -1926,3 +1926,53 @@ - - assert.Nil(suite.T(), result) - } -+ -+type withdrawalConsent struct { -+ providers.ConsentProvider -+ appID, userID string -+ calls int -+} -+ -+func (c *withdrawalConsent) WithdrawAuthenticatedConsent(_ context.Context, appID, userID string) *tidcommon.ServiceError { -+ c.appID = appID -+ c.userID = userID -+ c.calls++ -+ return nil -+} -+func (suite *ConsentExecutorTestSuite) TestCitizenWithdrawalAndDeclineAreDistinct() { -+ ctx := buildConsentNodeContext() -+ policy := &providers.DelegationConsentConfig{Purpose: "Read status", Resource: "urn:records", ScopeFields: map[string][]string{"read": {"status"}}} -+ ctx.Application.LoginConsent = &providers.LoginConsentConfig{ValidityPeriod: 300, Delegation: policy} -+ raw, _ := json.Marshal(policy) -+ ctx.RuntimeData[providers.DelegationConsentPolicyKey] = string(raw) -+ withdrawer := &withdrawalConsent{ConsentProvider: suite.mockConsentEnforcer} -+ suite.executor.consentEnforcer = withdrawer -+ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false}` -+ response, err := suite.executor.handleConsentDecisions(ctx, &providers.ExecutorResponse{}, "ou", "server-agent", "verified-citizen") -+ suite.NoError(err) -+ suite.Equal(providers.ExecFailure, response.Status) -+ suite.Zero(withdrawer.calls) -+ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false,"reason":"withdrawal"}` -+ response, err = suite.executor.handleConsentDecisions(ctx, &providers.ExecutorResponse{}, "ou", "server-agent", "verified-citizen") -+ suite.NoError(err) -+ suite.Equal(providers.ExecFailure, response.Status) -+ suite.Equal("CITIZEN-CONSENT-WITHDRAWN", response.Error.Code) -+ suite.Equal(1, withdrawer.calls) -+ suite.Equal("server-agent", withdrawer.appID) -+ suite.Equal("verified-citizen", withdrawer.userID) -+ suite.mockConsentEnforcer.AssertNotCalled(suite.T(), "RecordConsent", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) -+} -+ -+func (suite *ConsentExecutorTestSuite) TestCitizenWithdrawalRequiresVerifiedHuman() { -+ ctx := buildConsentNodeContext() -+ ctx.Application.LoginConsent = &providers.LoginConsentConfig{ValidityPeriod: 300, Delegation: &providers.DelegationConsentConfig{Purpose: "Read status", Resource: "urn:records", ScopeFields: map[string][]string{"read": {"status"}}}} -+ ctx.UserInputs[userInputConsentDecisions] = `{"approved":false,"reason":"withdrawal"}` -+ suite.executor.Executor.(*coremock.ExecutorInterfaceMock).On("ValidatePrerequisites", ctx, mock.Anything, mock.Anything).Return(true) -+ ref := buildConsentEntityRef() -+ ref.EntityCategory = string(providers.EntityCategoryAgent) -+ suite.mockAuthnProvider.On("GetEntityReference", mock.Anything, mock.Anything).Return(buildConsentAuthUser(), ref, (*tidcommon.ServiceError)(nil)) -+ response, err := suite.executor.Execute(ctx) -+ suite.NoError(err) -+ suite.Equal(providers.ExecFailure, response.Status) -+ suite.Equal(ErrUserNotAuthenticated.Code, response.Error.Code) -+} -diff --git a/backend/internal/flow/executor/oidc_auth_executor.go b/backend/internal/flow/executor/oidc_auth_executor.go ---- a/backend/internal/flow/executor/oidc_auth_executor.go -+++ b/backend/internal/flow/executor/oidc_auth_executor.go -@@ -131,6 +131,9 @@ - return nil, errors.New("OIDC nonce is missing in the authorization flow") - } - execResp.RuntimeData[common.RuntimeKeyOIDCNonce] = nonce -+ if verifier, ok := metadata["code_verifier"]; ok { -+ execResp.RuntimeData["oidc_code_verifier"] = verifier -+ } - - return metadata, nil - } -@@ -150,6 +153,11 @@ - // Validate the OAuth state parameter to prevent CSRF attacks. - // State is validated only when the client sends it back. Clients that handle CSRF - // protection client-side (e.g., via sessionStorage) may omit it. -+ if ctx.RuntimeData["oidc_code_verifier"] != "" && (ctx.RuntimeData[common.RuntimeKeyOAuthState] == "" || ctx.UserInputs[userInputState] != ctx.RuntimeData[common.RuntimeKeyOAuthState]) { -+ execResp.Status = providers.ExecFailure -+ execResp.Error = &ErrInvalidOAuthState -+ return nil -+ } - if returnedState, ok := ctx.UserInputs[userInputState]; ok && returnedState != "" { - expectedState := ctx.RuntimeData[common.RuntimeKeyOAuthState] - if returnedState != expectedState { -@@ -186,8 +194,9 @@ - IDPID: idpID, - IDPType: o.idpType, - AuthorizationData: authncm.AuthorizationData{ -- Code: code, -- Nonce: ctx.RuntimeData[common.RuntimeKeyOIDCNonce], -+ Code: code, -+ Nonce: ctx.RuntimeData[common.RuntimeKeyOIDCNonce], -+ CodeVerifier: ctx.RuntimeData["oidc_code_verifier"], - }, - }, - } -diff --git a/backend/internal/flow/executor/oidc_auth_executor_test.go b/backend/internal/flow/executor/oidc_auth_executor_test.go ---- a/backend/internal/flow/executor/oidc_auth_executor_test.go -+++ b/backend/internal/flow/executor/oidc_auth_executor_test.go -@@ -1146,3 +1146,14 @@ - assert.Contains(suite.T(), err.Error(), "OIDC authentication failed") - suite.mockAuthnProvider.AssertExpectations(suite.T()) - } -+ -+func (suite *OIDCAuthExecutorTestSuite) TestPrivateFederationRequiresReturnedState() { -+ for _, returned := range []string{"", "different-state"} { -+ ctx := &providers.NodeContext{ExecutionID: "fixture", UserInputs: map[string]string{"code": "synthetic-code", "state": returned}, RuntimeData: map[string]string{"oidc_code_verifier": "server-held", common.RuntimeKeyOAuthState: "expected-state"}} -+ resp := &providers.ExecutorResponse{} -+ err := suite.executor.ProcessAuthFlowResponse(ctx, resp) -+ suite.NoError(err) -+ suite.Equal(providers.ExecFailure, resp.Status) -+ suite.Equal(ErrInvalidOAuthState.Code, resp.Error.Code) -+ } -+} -diff --git a/backend/internal/idp/constants.go b/backend/internal/idp/constants.go ---- a/backend/internal/idp/constants.go -+++ b/backend/internal/idp/constants.go -@@ -7,7 +7,12 @@ - - // IDP property names. - const ( -- PropClientID = "client_id" -+ PropClientID = "client_id" -+ PropTokenEndpointAuthMethod = "token_endpoint_auth_method" -+ PropIDTokenSigningAlg = "id_token_signing_alg" -+ PropUserInfoSigningAlg = "userinfo_signing_alg" -+ PropRequiredClaims = "required_claims" -+ - PropClientSecret = "client_secret" - PropRedirectURI = "redirect_uri" - PropScopes = "scopes" -@@ -102,6 +107,10 @@ - PropTokenExchangeEnabled, - PropTrustedTokenAudience, - PropIDJagEnabled, -+ PropTokenEndpointAuthMethod, -+ PropIDTokenSigningAlg, -+ PropUserInfoSigningAlg, -+ PropRequiredClaims, - }, - Defaults: map[string]string{}, - }, -diff --git a/backend/internal/idp/utils.go b/backend/internal/idp/utils.go ---- a/backend/internal/idp/utils.go -+++ b/backend/internal/idp/utils.go -@@ -345,6 +345,20 @@ - requiredProps = teProps - } - } -+ if idpType == providers.IDPTypeOIDC { -+ if method, ok := filteredPropsMap[PropTokenEndpointAuthMethod]; ok { -+ value, err := method.GetValue() -+ if err != nil || value != "private_key_jwt" { -+ return nil, &ErrorInvalidIDPProperty -+ } -+ if _, exists := filteredPropsMap[PropClientSecret]; exists { -+ return nil, &ErrorInvalidIDPProperty -+ } -+ requiredProps = []string{PropClientID, PropRedirectURI, PropAuthorizationEndpoint, -+ PropTokenEndpoint, PropUserInfoEndpoint, PropJwksEndpoint, PropIssuer, -+ PropIDTokenSigningAlg, PropUserInfoSigningAlg, PropRequiredClaims} -+ } -+ } - for _, requiredProp := range requiredProps { - if !slices.Contains(filteredPropKeys, requiredProp) { - return nil, tidcommon.CustomServiceError(ErrorInvalidIDPProperty, tidcommon.I18nMessage{ -diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go ---- a/backend/internal/oauth/init.go -+++ b/backend/internal/oauth/init.go -@@ -56,6 +56,7 @@ - enforcementService revocation.EnforcementServiceInterface, - revocationSvc revocation.RevocationServiceInterface, - cfg oauthconfig.Config, -+ consentProvider providers.ConsentProvider, - ) (tokenservice.TokenValidatorInterface, error) { - jwks.Initialize(mux, runtimeCrypto) - httpClient := syshttp.NewHTTPClientWithCheckRedirect(func(req *http.Request, _ []*http.Request) error { -@@ -96,7 +97,7 @@ - grantHandlerProvider := granthandlers.Initialize( - jwtService, oauth2AuthzService, tokenBuilder, tokenValidator, - attributeCacheSvc, ouService, authzService, actorProvider, resourceService, -- cibaService, revocationSvc, revocationSvc, cfg) -+ cibaService, revocationSvc, revocationSvc, cfg, consentProvider) - - token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider, - scopeValidator, observabilitySvc, discoveryService, dpopVerifier, jtiStore, cfg) -diff --git a/backend/internal/oauth/oauth2/authz/service.go b/backend/internal/oauth/oauth2/authz/service.go ---- a/backend/internal/oauth/oauth2/authz/service.go -+++ b/backend/internal/oauth/oauth2/authz/service.go -@@ -408,6 +408,20 @@ - } - } - -+ if app.EntityCategory == providers.EntityCategoryAgent { -+ inbound, err := as.inboundClient.GetInboundClientByID(ctx, app.ID) -+ if providers.ValidateCitizenCodeClient(app) != nil || err != nil || inbound == nil || inbound.LoginConsent == nil || -+ inbound.LoginConsent.Delegation.Validate() != nil || inbound.LoginConsent.ValidityPeriod <= 0 || inbound.LoginConsent.ValidityPeriod > 300 || -+ len(oauthParams.Resources) != 1 || oauthParams.Resources[0] != inbound.LoginConsent.Delegation.Resource || len(oauthParams.PermissionScopes) == 0 { -+ return nil, &AuthorizationError{Code: oauth2const.ErrorInvalidRequest, Message: "Citizen agent authorization configuration or resource is invalid"} -+ } -+ for _, scope := range oauthParams.PermissionScopes { -+ if _, ok := inbound.LoginConsent.Delegation.ScopeFields[scope]; !ok { -+ return nil, &AuthorizationError{Code: oauth2const.ErrorInvalidScope, Message: "Scope has no governed consent field description"} -+ } -+ } -+ } -+ - // Initiate flow with OAuth context. - runtimeData := map[string]string{ - flowcm.RuntimeKeyClientID: oauthParams.ClientID, -@@ -422,7 +436,7 @@ - if effectiveAcrValues != "" { - runtimeData[flowcm.RuntimeKeyRequestedAuthClasses] = effectiveAcrValues - } -- if slices.Contains(strings.Fields(oauthParams.Prompt), oauth2const.PromptConsent) { -+ if app.EntityCategory == providers.EntityCategoryAgent || slices.Contains(strings.Fields(oauthParams.Prompt), oauth2const.PromptConsent) { - runtimeData[flowcm.RuntimeKeyForceConsentReprompt] = "true" - } - if oauthParams.MaxAge != "" { -diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go ---- a/backend/internal/oauth/oauth2/granthandlers/authorization_code.go -+++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code.go -@@ -24,10 +24,12 @@ - - // authorizationCodeGrantHandler handles the authorization code grant type. - type authorizationCodeGrantHandler struct { -- authzService authz.AuthorizeServiceInterface -- tokenBuilder tokenservice.TokenBuilderInterface -- attributeCache attributecache.AttributeCacheServiceInterface -- resourceService providers.ResourceServerProvider -+ actorProvider providers.ActorProvider -+ consentValidator consentAuthorizationValidator -+ authzService authz.AuthorizeServiceInterface -+ tokenBuilder tokenservice.TokenBuilderInterface -+ attributeCache attributecache.AttributeCacheServiceInterface -+ resourceService providers.ResourceServerProvider - } - - // newAuthorizationCodeGrantHandler creates a new instance of AuthorizationCodeGrantHandler. -@@ -150,6 +152,18 @@ - accessTokenScopes = append(accessTokenScopes, downscopedNonOidc...) - } - -+ var consentBinding *providers.DelegationConsentBinding -+ if oauthApp.EntityCategory == providers.EntityCategoryAgent { -+ binding, err := h.validateCitizenConsent(ctx, oauthApp, authCode, attrs, accessTokenAudiences, accessTokenScopes) -+ if err != nil { -+ return nil, err -+ } -+ consentBinding = binding -+ stripCitizenAuthority(attrs) -+ } -+ delete(attrs, "registry_actor_kind") -+ delete(attrs, providers.DelegationConsentCacheKey) -+ delete(attrs, providers.DelegationConsentPolicyKey) - // Generate access token using tokenBuilder (attributes will be filtered in BuildAccessToken) - userSubConfig := oauthApp.UserAccessTokenConfig() - accessTokenCtx := &tokenservice.AccessTokenBuildContext{ -@@ -166,6 +180,26 @@ - ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), - DPoPJkt: dpop.GetJkt(ctx), - TokenFamilyID: authCode.TokenFamilyID, -+ } -+ if consentBinding != nil { -+ if accessTokenCtx.SubjectAttributes == nil { -+ accessTokenCtx.SubjectAttributes = make(map[string]interface{}) -+ } -+ accessTokenCtx.SubjectAttributes["registry_actor_kind"] = "agent" -+ accessTokenCtx.SubjectAttributes["registry_purpose"] = consentBinding.Policy.Purpose -+ if source := consentBinding.Policy.SubjectAttribute; source != "" { -+ accessTokenCtx.SubjectAttributes["registry_subject_person_reference"] = attrs[source] -+ } -+ if status := consentBinding.Policy.IdentityStatus; status != "" { -+ accessTokenCtx.SubjectAttributes["registry_identity_status"] = status -+ } -+ remaining := consentBinding.Deadline - time.Now().Unix() -+ if remaining <= 0 { -+ return nil, &model.ErrorResponse{Error: constants.ErrorInvalidGrant, ErrorDescription: "Citizen consent expired"} -+ } -+ if accessTokenCtx.ValidityPeriod > remaining { -+ accessTokenCtx.ValidityPeriod = remaining -+ } - } - if oauthApp.ShouldAppendActorClaim() { - accessTokenCtx.ActorClaims = &tokenservice.SubjectTokenClaims{Sub: oauthApp.ID} -diff --git a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go ---- a/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go -+++ b/backend/internal/oauth/oauth2/granthandlers/authorization_code_test.go -@@ -342,8 +342,7 @@ - includeActClaim bool - expectActor bool - }{ -- {name: "AgentClientAlwaysAppendsActor", entityCategory: providers.EntityCategoryAgent, -- includeActClaim: false, expectActor: true}, -+ - {name: "AppClientWithoutFlagOmitsActor", entityCategory: providers.EntityCategoryApp, - includeActClaim: false, expectActor: false}, - {name: "AppClientWithFlagAppendsActor", entityCategory: providers.EntityCategoryApp, -diff --git a/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go b/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/oauth/oauth2/granthandlers/citizen_consent.go -@@ -0,0 +1,70 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package granthandlers -+ -+import ( -+ "context" -+ "encoding/json" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/authz" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+ "reflect" -+ "slices" -+ "strings" -+ "time" -+) -+ -+type consentAuthorizationValidator interface { -+ ValidateConsentAuthorization(context.Context, providers.DelegationConsentBinding) *tidcommon.ServiceError -+} -+ -+func (h *authorizationCodeGrantHandler) validateCitizenConsent(ctx context.Context, c *providers.OAuthClient, code *authz.AuthorizationCode, attrs map[string]interface{}, resources []string, scopes []string) (*providers.DelegationConsentBinding, *model.ErrorResponse) { -+ refusal := &model.ErrorResponse{Error: constants.ErrorInvalidGrant, ErrorDescription: "Fresh citizen consent is missing, withdrawn, expired, or changed"} -+ if providers.ValidateCitizenCodeClient(c) != nil || h.actorProvider == nil || h.consentValidator == nil { -+ return nil, refusal -+ } -+ inbound, err := h.actorProvider.GetInboundClientByID(ctx, c.ID) -+ if err != nil || inbound == nil || inbound.LoginConsent == nil || inbound.LoginConsent.Delegation.Validate() != nil { -+ return nil, refusal -+ } -+ raw, ok := attrs[providers.DelegationConsentCacheKey].(string) -+ if !ok || len(raw) > 64*1024 { -+ return nil, refusal -+ } -+ var binding providers.DelegationConsentBinding -+ if json.Unmarshal([]byte(raw), &binding) != nil || binding.Subject != code.AuthorizedUserID || binding.AppID != c.ID || binding.AuthorizationID == "" || -+ binding.ValidityPeriod != inbound.LoginConsent.ValidityPeriod || binding.ValidityPeriod <= 0 || binding.ValidityPeriod > 300 || binding.Deadline <= time.Now().Unix() || !reflect.DeepEqual(binding.Policy, *inbound.LoginConsent.Delegation) || len(resources) != 1 || resources[0] != binding.Policy.Resource || len(binding.Scopes) == 0 { -+ return nil, refusal -+ } -+ for _, scope := range scopes { -+ if _, oidc := constants.StandardOIDCScopes[scope]; !oidc && !slices.Contains(binding.Scopes, scope) { -+ return nil, refusal -+ } -+ } -+ for key := range attrs { -+ if key != providers.DelegationConsentCacheKey && !slices.Contains(binding.Attributes, key) { -+ delete(attrs, key) -+ } -+ } -+ if name := binding.Policy.SubjectAttribute; name != "" { -+ value, ok := attrs[name].(string) -+ if !ok || strings.TrimSpace(value) == "" || len(value) > 1024 || !slices.Contains(binding.Attributes, name) { -+ return nil, refusal -+ } -+ } -+ if h.consentValidator.ValidateConsentAuthorization(ctx, binding) != nil { -+ return nil, refusal -+ } -+ return &binding, nil -+} -+ -+func stripCitizenAuthority(attrs map[string]interface{}) { -+ for key := range attrs { -+ if strings.HasPrefix(key, "registry_grant_") || key == "registry_approver" || key == "identity" || key == "registry_subject_person_reference" || key == "registry_identity_status" || key == providers.DelegationConsentCacheKey || key == providers.DelegationConsentPolicyKey { -+ delete(attrs, key) -+ } -+ } -+} -diff --git a/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go b/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go -new file mode 100644 ---- /dev/null -+++ b/backend/internal/oauth/oauth2/granthandlers/citizen_consent_test.go -@@ -0,0 +1,148 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+package granthandlers -+ -+import ( -+ "context" -+ "encoding/json" -+ "github.com/stretchr/testify/mock" -+ "github.com/stretchr/testify/require" -+ "github.com/thunder-id/thunderid/internal/attributecache" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/authz" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" -+ "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" -+ tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" -+ "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" -+ "testing" -+ "time" -+) -+ -+type citizenActor struct { -+ providers.ActorProvider -+ inbound *providers.InboundClient -+} -+ -+func (a citizenActor) GetInboundClientByID(context.Context, string) (*providers.InboundClient, *tidcommon.ServiceError) { -+ return a.inbound, nil -+} -+ -+type citizenConsent struct{ failed bool } -+ -+func (c citizenConsent) ValidateConsentAuthorization(context.Context, providers.DelegationConsentBinding) *tidcommon.ServiceError { -+ if c.failed { -+ return &tidcommon.InternalServerError -+ } -+ return nil -+} -+ -+func citizenFixture() (*authorizationCodeGrantHandler, *providers.OAuthClient, *authz.AuthorizationCode, providers.DelegationConsentBinding) { -+ policy := providers.DelegationConsentConfig{SubjectAttribute: "person_reference", IdentityStatus: "active", Purpose: "Read eligibility", Resource: "urn:registry:citizen", ScopeFields: map[string][]string{"records:read": {"eligibility"}}} -+ client := &providers.OAuthClient{ID: "native-agent-entity", ClientID: "oauth-client", EntityCategory: providers.EntityCategoryAgent, -+ PKCERequired: true, GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, ResponseTypes: []providers.ResponseType{providers.ResponseTypeCode}, RedirectURIs: []string{"https://agent.example/callback"}, -+ Token: &providers.OAuthTokenConfig{AccessToken: &providers.AccessTokenConfig{UserConfig: &providers.AccessTokenSubConfig{ValidityPeriod: 300, Attributes: []string{"person_reference"}}}}} -+ binding := providers.DelegationConsentBinding{ConsentID: "consent", AuthorizationID: "fresh-authorization", UserID: "native-citizen", Subject: "pairwise-citizen", AppID: client.ID, ValidityPeriod: 300, Deadline: time.Now().Unix() + 120, Policy: policy, Scopes: []string{"records:read"}, Attributes: []string{"person_reference"}} -+ h := &authorizationCodeGrantHandler{actorProvider: citizenActor{inbound: &providers.InboundClient{LoginConsent: &providers.LoginConsentConfig{Delegation: &policy, ValidityPeriod: 300}}}, consentValidator: citizenConsent{}} -+ return h, client, &authz.AuthorizationCode{AuthorizedUserID: "pairwise-citizen"}, binding -+} -+func TestCitizenCodeRequiresTrustedCurrentBinding(t *testing.T) { -+ for _, scenario := range []string{"ok", "missing-cache", "forged-json", "other-subject", "other-agent", "expired", "purpose-changed", "fields-changed", "resource-changed", "scope-increase", "lookup-failed", "mixed-grants", "refresh", "pkce-disabled", "long-token", "grant-attributes", "missing-person-reference", "status-policy-changed", "duration-policy-changed"} { -+ t.Run(scenario, func(t *testing.T) { -+ h, c, code, binding := citizenFixture() -+ resources := []string{binding.Policy.Resource} -+ scopes := []string{"openid", "records:read"} -+ switch scenario { -+ case "duration-policy-changed": -+ binding.ValidityPeriod = 30 -+ case "status-policy-changed": -+ binding.Policy.IdentityStatus = "inactive" -+ case "other-subject": -+ code.AuthorizedUserID = "other-citizen" -+ case "other-agent": -+ c.ID = "other-agent" -+ case "expired": -+ binding.Deadline = time.Now().Unix() - 1 -+ case "purpose-changed": -+ binding.Policy.Purpose = "unapproved purpose" -+ case "fields-changed": -+ binding.Policy.ScopeFields = map[string][]string{"records:read": {"name", "address"}} -+ case "resource-changed": -+ resources = []string{"urn:other"} -+ case "scope-increase": -+ scopes = append(scopes, "records:write") -+ case "lookup-failed": -+ h.consentValidator = citizenConsent{failed: true} -+ case "mixed-grants": -+ c.GrantTypes = append(c.GrantTypes, providers.GrantTypeClientCredentials) -+ case "refresh": -+ c.GrantTypes = append(c.GrantTypes, providers.GrantTypeRefreshToken) -+ case "pkce-disabled": -+ c.PKCERequired = false -+ case "long-token": -+ c.Token.AccessToken.UserConfig.ValidityPeriod = 301 -+ case "grant-attributes": -+ c.Token.AccessToken.UserConfig.Attributes = append(c.Token.AccessToken.UserConfig.Attributes, "registry_grant_id") -+ } -+ raw, _ := json.Marshal(binding) -+ attrs := map[string]interface{}{providers.DelegationConsentCacheKey: string(raw), "person_reference": "synthetic-ref", "unconsented": "hidden"} -+ if scenario == "missing-person-reference" { -+ delete(attrs, "person_reference") -+ } -+ if scenario == "missing-cache" { -+ delete(attrs, providers.DelegationConsentCacheKey) -+ } -+ if scenario == "forged-json" { -+ attrs[providers.DelegationConsentCacheKey] = map[string]string{"AuthorizationID": "forged"} -+ } -+ result, err := h.validateCitizenConsent(context.Background(), c, code, attrs, resources, scopes) -+ if scenario == "ok" { -+ require.Nil(t, err) -+ require.NotNil(t, result) -+ require.NotContains(t, attrs, "unconsented") -+ } else { -+ require.NotNil(t, err) -+ require.Nil(t, result) -+ } -+ }) -+ } -+} -+ -+func (suite *AuthorizationCodeGrantHandlerTestSuite) TestCitizenAgentEmitsOnlyConsentedAuthority() { -+ base, c, _, binding := citizenFixture() -+ c.ClientID = testClientID -+ c.RedirectURIs = []string{testClientCallbackURL} -+ c.Token.AccessToken.UserConfig.Attributes = []string{"person_reference", "registry_actor_kind", "registry_purpose"} -+ suite.handler.actorProvider = base.actorProvider -+ suite.handler.consentValidator = base.consentValidator -+ suite.testAuthzCode.AuthorizedUserID = binding.Subject -+ suite.testAuthzCode.Resources = []string{binding.Policy.Resource} -+ suite.testAuthzCode.Scopes = "records:read" -+ suite.testAuthzCode.CodeChallenge = testCodeChallenge -+ suite.testAuthzCode.CodeChallengeMethod = "S256" -+ suite.testAuthzCode.AttributeCacheID = "citizen-cache" -+ suite.testTokenReq.Resources = []string{binding.Policy.Resource} -+ suite.testTokenReq.CodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" -+ raw, _ := json.Marshal(binding) -+ attrs := map[string]interface{}{providers.DelegationConsentCacheKey: string(raw), "person_reference": "synthetic-ref", "registry_actor_kind": "human", "registry_purpose": "forged", "registry_grant_id": "forged", "registry_subject_person_reference": "forged", "registry_identity_status": "forged", "identity": map[string]string{"other": "forged"}} -+ suite.mockAttrCacheService.On("GetAttributeCache", mock.Anything, "citizen-cache").Return(&attributecache.AttributeCache{Attributes: attrs}, nil) -+ suite.mockAuthzService.On("GetAuthorizationCodeDetails", mock.Anything, testClientID, "test-auth-code").Return(&suite.testAuthzCode, nil) -+ var captured *tokenservice.AccessTokenBuildContext -+ suite.mockTokenBuilder.On("BuildAccessToken", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { captured = args.Get(1).(*tokenservice.AccessTokenBuildContext) }).Return(&model.TokenDTO{Token: "synthetic-access", TokenType: constants.TokenTypeBearer}, nil) -+ result, err := suite.handler.HandleGrant(context.Background(), suite.testTokenReq, c) -+ suite.Nil(err) -+ suite.NotNil(result) -+ suite.NotNil(captured) -+ suite.Equal(binding.Subject, captured.Subject) -+ suite.Equal(c.ID, captured.ActorClaims.Sub) -+ suite.Equal(testClientID, captured.ClientID) -+ suite.Equal("agent", captured.SubjectAttributes["registry_actor_kind"]) -+ suite.Equal(binding.Policy.Purpose, captured.SubjectAttributes["registry_purpose"]) -+ suite.Equal("synthetic-ref", captured.SubjectAttributes["person_reference"]) -+ suite.Equal("synthetic-ref", captured.SubjectAttributes["registry_subject_person_reference"]) -+ suite.Equal("active", captured.SubjectAttributes["registry_identity_status"]) -+ suite.NotContains(captured.SubjectAttributes, "registry_grant_id") -+ suite.NotContains(captured.SubjectAttributes, "identity") -+ suite.NotContains(captured.SubjectAttributes, providers.DelegationConsentCacheKey) -+ suite.LessOrEqual(captured.ValidityPeriod, int64(120)) -+ suite.Greater(captured.ValidityPeriod, int64(0)) -+} -diff --git a/backend/internal/oauth/oauth2/granthandlers/init.go b/backend/internal/oauth/oauth2/granthandlers/init.go ---- a/backend/internal/oauth/oauth2/granthandlers/init.go -+++ b/backend/internal/oauth/oauth2/granthandlers/init.go -@@ -31,8 +31,9 @@ - refreshTokenRevoker revocation.RefreshTokenRevokerInterface, - criteriaRevoker revocation.CriteriaRevokerInterface, - cfg oauthconfig.Config, -+ consentProvider providers.ConsentProvider, - ) GrantHandlerProviderInterface { -- return newGrantHandlerProvider( -+ result := newGrantHandlerProvider( - jwtService, - oauth2AuthzService, - tokenBuilder, -@@ -47,4 +48,11 @@ - criteriaRevoker, - cfg, - ) -+ if p, ok := result.(*GrantHandlerProvider); ok { -+ if h, ok := p.authorizationCodeGrantHandler.(*authorizationCodeGrantHandler); ok { -+ h.actorProvider = actorProvider -+ h.consentValidator, _ = consentProvider.(consentAuthorizationValidator) -+ } -+ } -+ return result - } -diff --git a/backend/internal/oauth/oauth2/token/service.go b/backend/internal/oauth/oauth2/token/service.go ---- a/backend/internal/oauth/oauth2/token/service.go -+++ b/backend/internal/oauth/oauth2/token/service.go -@@ -116,6 +116,9 @@ - } - } - -+ if oauthApp.EntityCategory == providers.EntityCategoryAgent && oauthApp.IsAllowedGrantType(providers.GrantTypeAuthorizationCode) && providers.ValidateCitizenCodeClient(oauthApp) != nil { -+ return nil, &model.ErrorResponse{Error: constants.ErrorUnauthorizedClient, ErrorDescription: "Citizen agents require a closed authorization-code configuration"} -+ } - // Validate grant type against the application. - if !oauthApp.IsAllowedGrantType(grantType) { - publishTokenIssuanceFailedEvent(ts.observabilitySvc, ctx, clientID, grantTypeStr, scopeStr, -diff --git a/backend/pkg/thunderidengine/engine.go b/backend/pkg/thunderidengine/engine.go ---- a/backend/pkg/thunderidengine/engine.go -+++ b/backend/pkg/thunderidengine/engine.go -@@ -221,7 +221,7 @@ - engineCtx.jweService, engineCtx.flowExecService, engineCtx.observabilitySvc, engineCtx.runtimeCryptoSvc, - engineCtx.ouProvider, engineCtx.attributeCacheService, engineCtx.authzProvider, engineCtx.resourceProvider, - engineCtx.i18nProvider, engineCtx.idpProvider, engineCtx.dpopVerifier, engineCtx.runtimeStoreProvider, -- engineCtx.transactioner, revocationEnforcer, revocationService, oauthConfig) -+ engineCtx.transactioner, revocationEnforcer, revocationService, oauthConfig, engineCtx.consentProvider) - if err != nil { - logger.Fatal(ctx, "Failed to initialize OAuth services", log.Error(err)) - } -diff --git a/backend/pkg/thunderidengine/providers/delegation.go b/backend/pkg/thunderidengine/providers/delegation.go -new file mode 100644 ---- /dev/null -+++ b/backend/pkg/thunderidengine/providers/delegation.go -@@ -0,0 +1,92 @@ -+// Copyright 2026 The ThunderID Authors -+// SPDX-License-Identifier: Apache-2.0 -+ -+package providers -+ -+import ( -+ "errors" -+ "net/url" -+ "slices" -+ "strings" -+) -+ -+// DelegationConsentConfig describes the governed downstream use shown to a -+// citizen. ScopeFields lists the exact fields each admitted permission exposes. -+type DelegationConsentConfig struct { -+ SubjectAttribute string `json:"subjectAttribute,omitempty" yaml:"subjectAttribute,omitempty"` -+ IdentityStatus string `json:"identityStatus,omitempty" yaml:"identityStatus,omitempty"` -+ Purpose string `json:"purpose" yaml:"purpose"` -+ Resource string `json:"resource" yaml:"resource"` -+ ScopeFields map[string][]string `json:"scopeFields" yaml:"scopeFields"` -+} -+ -+// DelegationConsentBinding is server-only flow/cache state, never a user claim. -+// AuthorizationID changes on every native consent update, including reapproval. -+type DelegationConsentBinding struct { -+ ConsentID string -+ AuthorizationID string -+ AppID string -+ UserID string -+ Subject string -+ ValidityPeriod int64 -+ Deadline int64 -+ Policy DelegationConsentConfig -+ Scopes []string -+ Attributes []string -+} -+ -+const DelegationConsentCacheKey = "__registry_citizen_consent" -+const DelegationConsentPolicyKey = "__registry_citizen_policy" -+ -+func (c *DelegationConsentConfig) Validate() error { -+ invalid := errors.New("citizen delegation requires a purpose, resource, and explicit scope field descriptions") -+ if c == nil || strings.TrimSpace(c.Purpose) == "" || len(c.Purpose) > 1024 || len(c.ScopeFields) == 0 || len(c.ScopeFields) > 64 { -+ return invalid -+ } -+ if len(c.SubjectAttribute) > 128 || strings.ContainsAny(c.SubjectAttribute, " \t\r\n") || strings.HasPrefix(c.SubjectAttribute, "registry_") || strings.HasPrefix(c.SubjectAttribute, "_") || c.IdentityStatus != "" && c.IdentityStatus != "active" { -+ return invalid -+ } -+ resource, err := url.Parse(c.Resource) -+ if err != nil || resource.Scheme == "" || resource.Fragment != "" || resource.User != nil { -+ return invalid -+ } -+ for scope, fields := range c.ScopeFields { -+ if scope == "" || len(scope) > 256 || strings.ContainsAny(scope, " \t\r\n") || len(fields) == 0 || len(fields) > 64 { -+ return invalid -+ } -+ seen := map[string]bool{} -+ for _, field := range fields { -+ if field == "" || len(field) > 256 || strings.ContainsAny(field, "\r\n") || seen[field] { -+ return invalid -+ } -+ seen[field] = true -+ } -+ } -+ return nil -+} -+ -+// ValidateCitizenCodeClient also runs at authorization and redemption, so a -+// changed registration cannot unlock another issuance path for a citizen client. -+func ValidateCitizenCodeClient(c *OAuthClient) error { -+ invalid := errors.New("citizen agents require authorization_code only, PKCE, exact redirects, and at most 300 second access tokens") -+ if c == nil || c.EntityCategory != EntityCategoryAgent || c.ID == "" || len(c.GrantTypes) != 1 || c.GrantTypes[0] != GrantTypeAuthorizationCode || -+ !c.PKCERequired || len(c.ResponseTypes) != 1 || c.ResponseTypes[0] != ResponseTypeCode || len(c.RedirectURIs) == 0 { -+ return invalid -+ } -+ cfg := c.UserAccessTokenConfig() -+ if cfg == nil || cfg.ValidityPeriod <= 0 || cfg.ValidityPeriod > 300 { -+ return invalid -+ } -+ for _, uri := range c.RedirectURIs { -+ u, err := url.Parse(uri) -+ if err != nil || u.Scheme == "" || u.Fragment != "" || u.User != nil || strings.Contains(uri, "*") { -+ return invalid -+ } -+ } -+ for _, attr := range cfg.Attributes { -+ if strings.HasPrefix(attr, "registry_grant_") || slices.Contains([]string{"registry_approver", DelegationConsentCacheKey, DelegationConsentPolicyKey}, attr) { -+ return invalid -+ } -+ } -+ return nil -+} -diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go ---- a/backend/pkg/thunderidengine/providers/model.go -+++ b/backend/pkg/thunderidengine/providers/model.go -@@ -722,7 +722,8 @@ - - // LoginConsentConfig is the login consent configuration. - type LoginConsentConfig struct { -- ValidityPeriod int64 `json:"validityPeriod" yaml:"validityPeriod" jsonschema:"Consent validity period in seconds. 0 means never expire."` -+ Delegation *DelegationConsentConfig `json:"delegation,omitempty" yaml:"delegation,omitempty"` -+ ValidityPeriod int64 `json:"validityPeriod" yaml:"validityPeriod" jsonschema:"Consent validity period in seconds. 0 means never expire."` - } - - // Entity represents a unified identity principal returned by the entity provider. -diff --git a/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx b/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx ---- a/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx -+++ b/frontend/packages/design/src/components/flow/FlowComponentRenderer.tsx -@@ -116,6 +116,16 @@ - const isExpiredOnMount = hasTimer && expiresIn <= 0; - - if (hasConsent) { -+ const findSubmit = (items: FlowComponent[]): FlowComponent | undefined => { -+ for (const item of items) { -+ if (item.eventType?.toUpperCase() === 'SUBMIT') return item; -+ const child = findSubmit((item.components ?? []) as FlowComponent[]); -+ if (child) return child; -+ } -+ return undefined; -+ }; -+ const withdrawalAction = findSubmit([comp]); -+ const canWithdraw = additionalData?.['citizenConsentWithdrawal'] === 'true' && withdrawalAction != null; - return ( - <> - onSubmit(withdrawalAction!, {...values, __consent_reason__: 'withdrawal'}) : undefined -+ } - /> - { -+ it('submits withdrawal through the native flow only when the server exposes it', () => { -+ const action = {type: 'ACTION', id: 'allow', eventType: 'SUBMIT', label: 'Allow'}; -+ const component = { -+ type: 'BLOCK', -+ id: 'consent', -+ components: [{type: 'CONSENT_INPUT', id: 'input'}, action], -+ } as unknown as EmbeddedFlowComponent; -+ const onSubmit = vi.fn(); -+ renderWithProviders( -+ , -+ ); -+ fireEvent.click(screen.getByRole('button', {name: 'Withdraw this agent’s access'})); -+ expect(onSubmit).toHaveBeenCalledWith(action, {__consent_reason__: 'withdrawal'}); -+ }); -+}); -diff --git a/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx b/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx ---- a/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx -+++ b/frontend/packages/design/src/components/flow/adapters/ConsentAdapter.tsx -@@ -9,7 +9,7 @@ - type ConsentRenderProps, - } from '@thunderid/react'; - import {cn} from '@thunderid/utils'; --import {Box, Divider, FormControlLabel, Switch, Typography} from '@wso2/oxygen-ui'; -+import {Box, Button, Divider, FormControlLabel, Switch, Typography} from '@wso2/oxygen-ui'; - import type {JSX} from 'react'; - - function isPermissionPurpose(purpose: ConsentPurpose): boolean { -@@ -43,6 +43,8 @@ - - interface ConsentAdapterProps { - consentData?: string | ConsentPurpose[] | {purposes: ConsentPurpose[]}; -+ onWithdraw?: () => void; -+ disabled?: boolean; - formValues: Record; - onInputChange: (name: string, value: string) => void; - } -@@ -140,6 +142,8 @@ - consentData = undefined, - formValues, - onInputChange, -+ onWithdraw, -+ disabled = false, - }: ConsentAdapterProps): JSX.Element | null { - if (!consentData) return null; - -@@ -147,8 +151,24 @@ - - {({purposes}: ConsentRenderProps) => ( - -+ {onWithdraw && ( -+ -+ -+ Withdrawing ends this agent's current consent. Declining the new request leaves earlier consent -+ unchanged. Already issued access may remain valid for up to five minutes. -+ -+ -+ -+ )} - {purposes.map((purpose, idx) => ( - -+ {purpose.description && ( -+ -+ {purpose.description} -+ -+ )} - {isPermissionPurpose(purpose) && purpose.optional && purpose.optional.length > 0 && ( - - -diff --git a/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx b/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx ---- a/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx -+++ b/frontend/packages/design/src/components/flow/adapters/__tests__/ConsentAdapter.test.tsx -@@ -109,3 +109,38 @@ - expect(switchFor('users').checked).toBe(true); - }); - }); -+ -+describe('ConsentAdapter purpose disclosure', () => { -+ it('shows the governed agent purpose, destination, fields and duration', () => { -+ const description = -+ 'Eligibility agent may use the citizen registry for eligibility. Fields: eligibility. Access lasts at most 300 seconds.'; -+ const consentData = JSON.stringify([ -+ { -+ purposeName: 'permissions:agent', -+ purposeId: 'agent', -+ type: 'permissions', -+ description, -+ essential: [], -+ optional: [{name: 'records:read'}], -+ }, -+ ]); -+ renderWithProviders(); -+ expect(screen.getByText(description)).toBeInTheDocument(); -+ }); -+}); -+ -+describe('Citizen withdrawal', () => { -+ it('offers explicit withdrawal separately from consent choices', () => { -+ const onWithdraw = vi.fn(); -+ renderWithProviders( -+ , -+ ); -+ fireEvent.click(screen.getByRole('button', {name: 'Withdraw this agent’s access'})); -+ expect(onWithdraw).toHaveBeenCalledOnce(); -+ expect(screen.getByText(/Already issued access may remain valid/)).toBeTruthy(); -+ }); -+ it('does not offer withdrawal without server authorization', () => { -+ renderWithProviders(); -+ expect(screen.queryByRole('button', {name: 'Withdraw this agent’s access'})).toBeNull(); -+ }); -+}); diff --git a/products/identity/thunderid/extension/test_build.py b/products/identity/thunderid/extension/test_build.py deleted file mode 100644 index d0cd24f5a7..0000000000 --- a/products/identity/thunderid/extension/test_build.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Source preparation must not trust a pre-extracted cache or archive paths.""" - -import io -from pathlib import Path -import tarfile -import tempfile -import unittest -import subprocess - -import build - - -class SourcePreparationTests(unittest.TestCase): - def test_applies_patch_inside_an_existing_parent_repository(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - subprocess.run(["git", "init", "--quiet", str(root)], check=True) - source = root / "target/upstream" - source.mkdir(parents=True) - (source / "example").write_text("before\n") - patch = root / "change.patch" - patch.write_text("diff --git a/example b/example\n--- a/example\n+++ b/example\n@@ -1 +1 @@\n-before\n+after\n") - build.apply_source_patch(source, patch) - self.assertEqual((source / "example").read_text(), "after\n") - - def archive(self, root, name="source/backend/main.go", kind=tarfile.REGTYPE): - archive = root / "source.tar.gz" - with tarfile.open(archive, "w:gz") as output: - member = tarfile.TarInfo(name) - member.type = kind - member.mode = 0o644 - member.size = 3 if kind == tarfile.REGTYPE else 0 - member.linkname = "../../outside" - output.addfile(member, io.BytesIO(b"pkg") if member.size else None) - return archive - - def test_extracts_verified_source_and_preserves_existing_destination(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - archive = self.archive(root) - destination = root / "out" - build.extract_source(archive, destination, build.sha256(archive)) - self.assertEqual((destination / "backend/main.go").read_bytes(), b"pkg") - with self.assertRaises(FileExistsError): - build.extract_source(archive, destination, build.sha256(archive)) - - def test_refuses_checksum_mismatch_before_extracting(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - archive = self.archive(root) - with self.assertRaisesRegex(ValueError, "checksum mismatch"): - build.extract_source(archive, root / "out", "0" * 64) - self.assertFalse((root / "out").exists()) - - def test_refuses_archive_escape_and_links(self): - for name, kind in [("source/../../outside", tarfile.REGTYPE), - ("/absolute", tarfile.REGTYPE), - ("source/link", tarfile.SYMTYPE)]: - with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: - root = Path(directory) - archive = self.archive(root, name, kind) - with self.assertRaisesRegex(ValueError, "unsupported"): - build.extract_source(archive, root / "out", build.sha256(archive)) - self.assertFalse((root / "out").exists()) - - -if __name__ == "__main__": - unittest.main() From 369e7cb9eacb82950408330009522dd1c45d9fb9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:00:09 +0700 Subject: [PATCH 033/120] fix(release): preserve published Mint inventory through v0.30.0 Signed-off-by: Jeremi Joslin --- release/manifests/registry-stack-beta-42.yaml | 6 +-- .../scripts/merge-release-binary-shards.py | 4 +- release/scripts/registry-release | 2 +- release/scripts/release_candidate.py | 5 +- .../test_merge_release_binary_shards.py | 12 ++++- release/scripts/test_registry_release.py | 5 +- release/scripts/test_release_candidate.py | 52 +++++++++++-------- 7 files changed, 52 insertions(+), 34 deletions(-) diff --git a/release/manifests/registry-stack-beta-42.yaml b/release/manifests/registry-stack-beta-42.yaml index a78c43a368..9133fac178 100644 --- a/release/manifests/registry-stack-beta-42.yaml +++ b/release/manifests/registry-stack-beta-42.yaml @@ -10,6 +10,7 @@ artifacts: relayctl: 0.30.0 evidence: 0.30.0 evidencectl: 0.30.0 + mint: 0.30.0 evidence-oid4vci: 0.30.0 evidencectl-installer: 0.30.0 relay-installer: 0.30.0 @@ -30,8 +31,3 @@ identifier_catalog: entry_count: 112 external: {} - -warnings: - - code: registry-mint-retired - classification: breaking-product-replacement - detail: Registry Mint binaries and images are no longer shipped. Configure a standards-based OAuth issuer; maintained local tooling uses pinned stock ThunderID 1.0.1. Existing Mint configuration and signing custody or durable pre-response issuance audit are not migrated. diff --git a/release/scripts/merge-release-binary-shards.py b/release/scripts/merge-release-binary-shards.py index 98f158c109..d57175324a 100755 --- a/release/scripts/merge-release-binary-shards.py +++ b/release/scripts/merge-release-binary-shards.py @@ -53,11 +53,11 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: f"relay-{tag}-linux-amd64", f"relayctl-{tag}-linux-amd64", ] - if parsed >= (0, 30, 0): + if parsed > (0, 30, 0): common.remove(f"mint-{tag}-linux-amd64") core.extend(common) for image_name in ("evidence", "mint", "relay"): - if image_name != "mint" or parsed < (0, 30, 0): + if image_name != "mint" or parsed <= (0, 30, 0): image_bins.append((image_name, f"{image_name}-{tag}-linux-amd64")) return {"core": core, "breg": breg, "casework": casework}, image_bins diff --git a/release/scripts/registry-release b/release/scripts/registry-release index e5c5d70fa9..7fab09e572 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -175,7 +175,7 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s if parsed_version is not None: artifact_names = set(artifacts) expected_inventory = set(RELAY_V2_ARTIFACT_INVENTORY) - if parsed_version >= (0, 30, 0): + if parsed_version > (0, 30, 0): expected_inventory.remove("mint") if parsed_version >= RELAY_INSTALLER_MINIMUM_VERSION: expected_inventory.add("relay-installer") diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 109c38f335..b7556c359f 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -55,7 +55,8 @@ BREG_RUNTIME_IMAGE_NAMES = DISCOVERY_RUNTIME_IMAGE_NAMES | { "breg" } -MINT_RETIREMENT_VERSION = (0, 30, 0) +MINT_RETIREMENT_VERSION = (0, 30, 1) +# v0.30.0 is already released. Every later version omits Mint. CASEWORK_RUNTIME_IMAGE_NAMES = (BREG_RUNTIME_IMAGE_NAMES - {"mint"}) | {"casework"} V2_TOP_LEVEL_FIELDS = { "schema_version", @@ -123,6 +124,8 @@ def _candidate_image_names(version: str) -> set[str]: return DISCOVERY_RUNTIME_IMAGE_NAMES if parsed < CASEWORK_RELEASE_MINIMUM_VERSION: return BREG_RUNTIME_IMAGE_NAMES + if parsed < MINT_RETIREMENT_VERSION: + return CASEWORK_RUNTIME_IMAGE_NAMES | {"mint"} return CASEWORK_RUNTIME_IMAGE_NAMES diff --git a/release/scripts/test_merge_release_binary_shards.py b/release/scripts/test_merge_release_binary_shards.py index 01f7cfc0e7..cdad926538 100644 --- a/release/scripts/test_merge_release_binary_shards.py +++ b/release/scripts/test_merge_release_binary_shards.py @@ -16,7 +16,7 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) -VERSION = "0.30.0" +VERSION = "0.30.1" TAG = f"v{VERSION}" BUILDER = "rust:fixture@sha256:" + "a" * 64 SOURCE_SHA = "1" * 40 @@ -155,10 +155,18 @@ def test_version_gates_select_the_historical_exact_rosters(self) -> None: rosters_030, images_030 = MODULE.rosters("0.30.0") self.assertEqual(2, len(rosters_030["casework"])) self.assertEqual( - ["discovery", "breg", "casework", "evidence", "relay"], + ["discovery", "breg", "casework", "evidence", "mint", "relay"], [name for name, _ in images_030], ) + def test_mint_is_retired_only_after_published_v0_30_0(self) -> None: + old, old_images = MODULE.rosters("0.30.0") + current, current_images = MODULE.rosters("0.30.1") + self.assertIn("mint-v0.30.0-linux-amd64", old["core"]) + self.assertNotIn("mint-v0.30.1-linux-amd64", current["core"]) + self.assertIn("mint", dict(old_images)) + self.assertNotIn("mint", dict(current_images)) + def test_rejects_incomplete_or_ambiguous_shards_before_staging_output(self) -> None: mutations = { "missing": lambda: (self.core / "bin" / CORE[0]).unlink(), diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 6cb1e2396e..389703935b 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -1704,7 +1704,7 @@ def test_casework_release_surface_begins_after_published_v0_29(self) -> None: for name in published_names } self.assertEqual([], module.artifact_inventory_errors("0.29.0", published)) - future = {name: "0.30.0" for name in published if name != "mint"} + future = {name: "0.30.0" for name in published} future.update( { "casework": "0.30.0", @@ -1713,6 +1713,9 @@ def test_casework_release_surface_begins_after_published_v0_29(self) -> None: } ) self.assertEqual([], module.artifact_inventory_errors("0.30.0", future)) + retired = {name: "0.30.1" for name in future if name != "mint"} + self.assertEqual([], module.artifact_inventory_errors("0.30.1", retired)) + self.assertNotEqual([], module.artifact_inventory_errors("0.30.0", retired)) del future["caseworkctl"] self.assertNotEqual([], module.artifact_inventory_errors("0.30.0", future)) diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index 49c9155b0b..0295d6fca3 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -155,7 +155,7 @@ def onboarding_repository(self) -> Path: "release/scripts/build-release-image.sh", "release/scripts/cleanup-release-candidates.py", ] - for image_name in self.module._candidate_image_names("0.30.0"): + for image_name in self.module._candidate_image_names("0.30.1"): paths.append(f"release/docker/Dockerfile.{image_name}") paths.append( "products/relay-v2/security/advisory-baseline.json" @@ -839,7 +839,7 @@ def test_breg_image_joins_the_roster_at_v0_26(self) -> None: def test_casework_joins_only_the_future_v0_30_roster(self) -> None: self.assertNotIn("casework", self.module._candidate_image_names("0.29.0")) self.assertEqual( - {"breg", "casework", "discovery", "evidence", "relay"}, + {"breg", "casework", "discovery", "evidence", "mint", "relay"}, self.module._candidate_image_names("0.30.0"), ) historical = self.module._relay_v2_payload_inventory("0.29.0") @@ -851,6 +851,13 @@ def test_casework_joins_only_the_future_v0_30_roster(self) -> None: self.assertEqual("installer", future["casework-v0.30.0-install.sh"]) self.assertEqual("installer", future["casework-install.sh"]) + def test_retirement_preserves_published_v0_30_0(self) -> None: + for version, expected in (("0.30.0", True), ("0.30.1", False)): + with self.subTest(version=version): + self.assertEqual(expected, "mint" in self.module._candidate_image_names(version)) + inventory = self.module._relay_v2_payload_inventory(version) + self.assertEqual(expected, f"mint-v{version}-linux-amd64" in inventory) + def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: cases = ( ("0.20.2", "relay\n"), @@ -858,7 +865,8 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: ("0.24.0", "discovery evidence mint relay\n"), ("0.26.0", "breg discovery evidence mint relay\n"), ("0.29.0", "breg discovery evidence mint relay\n"), - ("0.30.0", "breg casework discovery evidence relay\n"), + ("0.30.0", "breg casework discovery evidence mint relay\n"), + ("0.30.1", "breg casework discovery evidence relay\n"), ) for version, expected in cases: with self.subTest(version=version): @@ -871,33 +879,33 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: self.assertEqual(expected, stdout.getvalue()) def test_image_onboarding_accepts_every_current_version_roster(self) -> None: - for version in ("0.30.0",): + for version in ("0.30.1",): with self.subTest(version=version): self.assertEqual( self.module._candidate_image_names(version), self.module.check_image_onboarding(ROOT, version), ) - def test_image_onboarding_at_0_30_0_requires_reviewed_casework_baseline(self) -> None: + def test_image_onboarding_after_0_30_0_requires_reviewed_casework_baseline(self) -> None: root = self.onboarding_repository() (root / "release/security/casework-advisory-baseline.json").unlink() with self.assertRaisesRegex( self.module.CandidateError, "casework advisory baseline is missing", ): - self.module.check_image_onboarding(root, "0.30.0") + self.module.check_image_onboarding(root, "0.30.1") self.assertEqual( - self.module._candidate_image_names("0.30.0"), + self.module._candidate_image_names("0.30.1"), self.module.check_image_onboarding( - root, "0.30.0", allow_missing_baseline=True + root, "0.30.1", allow_missing_baseline=True ), ) (root / "release/security/casework-advisory-baseline.json").write_text( json.dumps({"version": 4, "service": "casework"}), encoding="utf-8" ) self.assertEqual( - self.module._candidate_image_names("0.30.0"), - self.module.check_image_onboarding(root, "0.30.0"), + self.module._candidate_image_names("0.30.1"), + self.module.check_image_onboarding(root, "0.30.1"), ) def test_image_onboarding_rejects_a_noncanonical_version(self) -> None: @@ -916,7 +924,7 @@ def test_image_onboarding_rejects_a_missing_dockerfile(self) -> None: ): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) @@ -939,7 +947,7 @@ def test_image_onboarding_rejects_symlinked_release_inputs(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) shutil.rmtree(root) @@ -960,7 +968,7 @@ def test_image_onboarding_rejects_an_unsupported_build_name(self) -> None: ): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) @@ -980,7 +988,7 @@ def test_image_onboarding_rejects_a_missing_binary_staging_path(self) -> None: ): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) @@ -992,10 +1000,10 @@ def test_only_an_absent_baseline_has_an_explicit_allowance(self) -> None: self.module.CandidateError, "breg advisory baseline is missing", ): - self.module.check_image_onboarding(root, "0.30.0") + self.module.check_image_onboarding(root, "0.30.1") self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) cleanup = root / "release/scripts/cleanup-release-candidates.py" @@ -1012,7 +1020,7 @@ def test_only_an_absent_baseline_has_an_explicit_allowance(self) -> None: ): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) @@ -1027,7 +1035,7 @@ def test_image_onboarding_cli_reports_strict_failure_and_bootstrap_success( [ "check-image-onboarding", "--version", - "0.30.0", + "0.30.1", "--root", str(root), ] @@ -1040,7 +1048,7 @@ def test_image_onboarding_cli_reports_strict_failure_and_bootstrap_success( [ "check-image-onboarding", "--version", - "0.30.0", + "0.30.1", "--root", str(root), "--allow-missing-baseline", @@ -1062,7 +1070,7 @@ def test_image_onboarding_rejects_a_malformed_baseline(self) -> None: ): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) @@ -1078,7 +1086,7 @@ def test_image_onboarding_requires_v4_for_the_exact_service(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) shutil.rmtree(root) @@ -1106,7 +1114,7 @@ def test_image_onboarding_requires_structural_cleanup_identities(self) -> None: with self.assertRaisesRegex(self.module.CandidateError, error): self.module.check_image_onboarding( root, - "0.30.0", + "0.30.1", allow_missing_baseline=True, ) shutil.rmtree(root) From db500a11289b2996c50381f7de89293416e96088 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:03:34 +0700 Subject: [PATCH 034/120] fix(breg): align portability authority claims Signed-off-by: Jeremi Joslin --- crates/registry-breg/tests/issuer_portability.rs | 2 +- .../breg/acceptance/issuer-portability/tests/journeys.yaml | 3 +++ products/breg/scripts/test-issuer-portability.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/registry-breg/tests/issuer_portability.rs b/crates/registry-breg/tests/issuer_portability.rs index 6faee5fb08..994cbf93d0 100644 --- a/crates/registry-breg/tests/issuer_portability.rs +++ b/crates/registry-breg/tests/issuer_portability.rs @@ -104,7 +104,7 @@ fn authenticator( registry, verifier, keys, - AuthorityClaimConfig::new("registry_principal", Some("purpose".to_owned())), + AuthorityClaimConfig::new("registry_principal", Some("registry_purpose".to_owned())), ) .expect("explicit issuer configuration"), ) diff --git a/products/breg/acceptance/issuer-portability/tests/journeys.yaml b/products/breg/acceptance/issuer-portability/tests/journeys.yaml index dcdbe942c1..46c875a7f4 100644 --- a/products/breg/acceptance/issuer-portability/tests/journeys.yaml +++ b/products/breg/acceptance/issuer-portability/tests/journeys.yaml @@ -7,6 +7,9 @@ journeys: accessProfile: clerk claims: principal: urn:institution:service-clerk + scopes: [registry:read] + purpose: registry-administration + directClaims: {districts: district-a} request: operation: list expect: diff --git a/products/breg/scripts/test-issuer-portability.py b/products/breg/scripts/test-issuer-portability.py index 6a02b40da1..a5b41c146f 100644 --- a/products/breg/scripts/test-issuer-portability.py +++ b/products/breg/scripts/test-issuer-portability.py @@ -128,7 +128,7 @@ def realm(client_secret: str, password: str, callback: str, audience: str) -> di mapper("tenant assignment", "oidc-hardcoded-claim-mapper", { "claim.name": "tenant_claim", "claim.value": "tenant-a", "jsonType.label": "String"}), mapper("purpose", "oidc-hardcoded-claim-mapper", { - "claim.name": "purpose", "claim.value": PURPOSE, "jsonType.label": "String"}), + "claim.name": "registry_purpose", "claim.value": PURPOSE, "jsonType.label": "String"}), mapper("BREG resource", "oidc-audience-mapper", {"included.custom.audience": audience}), ] client = {"protocol": "openid-connect", "enabled": True, @@ -245,7 +245,7 @@ def journey(root: Path, bregctl: str, breg: str, docker: str, scopes: [registry:read] claims: registry_principal: urn:institution:service-clerk - purpose: registry-administration + registry_purpose: registry-administration districts: district-a tenant_claim: tenant-a """) From bdb3c7816fe5ecd44e752a6f8295b0dbec5bfaf2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:10:09 +0700 Subject: [PATCH 035/120] feat(casework): sign Evidence requester context Signed-off-by: Jeremi Joslin --- .../registry-casework-core/src/task_grant.rs | 106 +++++++++++++++++- crates/registry-casework/src/task_grants.rs | 90 ++++++++++++++- .../generated/registry-casework.openapi.json | 37 ++++++ products/casework/scripts/generate_openapi.py | 12 +- 4 files changed, 238 insertions(+), 7 deletions(-) diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index d7e9219596..2976380a50 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -31,12 +31,33 @@ pub struct TaskTemplate { pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, + /// Evidence-only requester context signed into the authority assertion. + /// BREG derives neither requester admission nor relying-party audience + /// from these fields and therefore forbids the block entirely. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_context: Option, /// Exact token identity keys mapped to governed source logical fields. /// Values are extracted from the approving caller's disclosed source read. pub subjects: BTreeMap, pub lifetime_seconds: u64, } +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EvidenceRequesterContext { + pub requester_tags: Vec, + pub audience: String, +} + +impl fmt::Debug for EvidenceRequesterContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EvidenceRequesterContext") + .field("requester_tags", &"") + .field("audience", &"") + .finish() + } +} + impl fmt::Debug for TaskTemplate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TaskTemplate") @@ -156,7 +177,12 @@ impl TaskTemplate { { return Err(TaskGrantError::Policy); } - self.bounds.check() + self.bounds.check()?; + match (&self.bounds, &self.evidence_context) { + (TaskGrantBounds::Evidence { .. }, Some(context)) => context.check(), + (TaskGrantBounds::Breg { .. }, None) => Ok(()), + _ => Err(TaskGrantError::Policy), + } } pub fn disclosed_subjects( @@ -180,6 +206,31 @@ impl TaskTemplate { } } +impl EvidenceRequesterContext { + fn check(&self) -> Result<(), TaskGrantError> { + if !unique(&self.requester_tags, 32) + || self + .requester_tags + .iter() + .any(|tag| !valid_evidence_tag(tag)) + || !registry_platform_httputil::valid_resource_uri(&self.audience) + { + return Err(TaskGrantError::Policy); + } + Ok(()) + } +} + +fn valid_evidence_tag(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 128 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..].iter().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-') + }) +} + /// Ephemeral exact values from one source read. Never serialized as a work-item view. pub struct TaskSubjectContext { pub binding: SourceBinding, @@ -332,6 +383,55 @@ mod tests { let bounds: TaskGrantBounds = serde_json::from_value(serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get","create"]}]})).unwrap(); assert!(bounds.check().is_ok()); } + + #[test] + fn evidence_templates_require_closed_requester_context_and_breg_forbids_it() { + let project: CaseworkProject = serde_json::from_value(serde_json::json!({ + "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, + "casework": {"id":"tasks", "version":"1"}, + "accessProfiles":[{"id":"staff", "principalClaim":"sub", "requiredScopes":["casework:staff"], "role":"staff"}], + "queues":[{"id":"review", "label":"Review"}], + "sources":[{"id":"source", "adapter":"test", "description":"Test source", "requests":[{"entity":"request", "queue":"review"}]}] + })) + .unwrap(); + let mut template: TaskTemplate = serde_json::from_value(serde_json::json!({ + "id":"evidence-check", "version":"1", "label":"Check evidence", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"evidence-task-agent", "resource":"urn:test:evidence", + "scopes":["evidence:invoke"], "purpose":"fixture-eligibility", + "bounds":{"type":"evidence", "requirement":"urn:test:requirement:adult"}, + "evidenceContext":{"requesterTags":["fixture-agency"], "audience":"https://relying.test/procedure"}, + "subjects":{"given_name":"given-name"}, "lifetimeSeconds":900 + })) + .unwrap(); + assert!(template.check(&project).is_ok()); + let context_debug = format!("{:?}", template.evidence_context.as_ref().unwrap()); + assert!(!context_debug.contains("fixture-agency")); + assert!(!context_debug.contains("relying.test")); + + let context = template.evidence_context.take(); + assert!(template.check(&project).is_err()); + template.evidence_context = context; + template.bounds = serde_json::from_value(serde_json::json!({ + "type":"breg", "permissions":[{"collection":"records", "operations":["get"]}] + })) + .unwrap(); + assert!(template.check(&project).is_err()); + + template.bounds = serde_json::from_value(serde_json::json!({ + "type":"evidence", "requirement":"urn:test:requirement:adult" + })) + .unwrap(); + template.evidence_context.as_mut().unwrap().requester_tags = vec![]; + assert!(template.check(&project).is_err()); + template.evidence_context.as_mut().unwrap().requester_tags = vec!["Fixture-Agency".into()]; + assert!(template.check(&project).is_err()); + template.evidence_context.as_mut().unwrap().requester_tags = vec!["fixture-agency".into()]; + template.evidence_context.as_mut().unwrap().audience = "relative-audience".into(); + assert!(template.check(&project).is_err()); + } } /// The exact authorization a human can approve after a current disclosed read. @@ -347,6 +447,8 @@ pub struct TaskTemplatePreview { pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_context: Option, pub subjects: BTreeMap, pub lifetime_seconds: u64, } @@ -372,6 +474,8 @@ pub struct TaskGrantView { pub scopes: Vec, pub purpose: String, pub bounds: TaskGrantBounds, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub evidence_context: Option, pub expires_at: u64, pub invalidated: bool, } diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index dadebb5c03..4a7ddaf3ac 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -364,12 +364,16 @@ impl TaskAuthority { now.checked_add(registry_casework_core::TASK_ASSERTION_LIFETIME_SECONDS) .ok_or(StoreError::Invalid)?, ); - let payload = json!({"iss":self.config.issuer,"sub":grant.template.agent.subject,"aud":self.config.exchange_audience, + let mut payload = json!({"iss":self.config.issuer,"sub":grant.template.agent.subject,"aud":self.config.exchange_audience, "iat":now,"nbf":now,"exp":expires,"jti":Uuid::new_v4(),"registry_actor_kind":"agent", "registry_grant_id":grant.id,"registry_grant_authority":grant.authority,"registry_grant_source_issuer":grant.source_issuer, "registry_grant_client":grant.template.client,"registry_grant_resource":grant.template.resource, "registry_purpose":grant.template.purpose,"registry_grant_exp":grant.expires_at, "registry_grant_bounds":grant.template.bounds,"scope":grant.template.scopes.join(" "),"identity":grant.subjects}); + if let Some(context) = &grant.template.evidence_context { + payload["evidence_tags"] = json!(context.requester_tags); + payload["evidence_audience"] = json!(context.audience); + } let header = json!({"alg":self.key.alg,"kid":self.key.kid,"typ":"JWT"}); let input = format!( "{}.{}", @@ -459,6 +463,7 @@ impl crate::CaseworkService { scopes: template.scopes.clone(), purpose: template.purpose.clone(), bounds: template.bounds.clone(), + evidence_context: template.evidence_context.clone(), subjects, lifetime_seconds: template.lifetime_seconds, }); @@ -712,11 +717,94 @@ fn grant_view(stored: &StoredTaskGrant) -> TaskGrantView { scopes: stored.grant.template.scopes.clone(), purpose: stored.grant.template.purpose.clone(), bounds: stored.grant.template.bounds.clone(), + evidence_context: stored.grant.template.evidence_context.clone(), expires_at: stored.grant.expires_at, invalidated: stored.invalidated, } } +#[cfg(test)] +mod evidence_assertion_tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use registry_casework_core::{IssuerPrincipal, SubjectRef}; + use std::collections::BTreeMap; + + #[test] + fn evidence_requester_context_is_signed_as_two_explicit_claims() { + let mut key = registry_platform_crypto::generate_private_jwk( + registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, + ) + .unwrap(); + key.alg = Some("RS256".into()); + key.kid = Some("task-authority-key".into()); + let authority = TaskAuthority { + config: crate::TaskAuthorityConfig { + id: "casework".into(), + issuer: "https://casework.test".into(), + exchange_audience: "https://issuer.test".into(), + signing_key_ref: "secret:unused".into(), + status_clients: BTreeMap::new(), + }, + key, + }; + let template: TaskTemplate = serde_json::from_value(json!({ + "id":"evidence-check", "version":"1", "label":"Check evidence", + "eligibleTeams":["team"], "eligibleProfiles":["staff"], "source":"source", + "itemKinds":["request"], "itemStates":["claimed"], + "agent":{"issuer":"https://issuer.test", "subject":"agent"}, + "client":"evidence-task-agent", "resource":"urn:test:evidence", + "scopes":["evidence:invoke"], "purpose":"fixture-eligibility", + "bounds":{"type":"evidence", "requirement":"urn:test:requirement:adult"}, + "evidenceContext":{"requesterTags":["fixture-agency", "benefits"], "audience":"https://relying.test/procedure"}, + "subjects":{"given_name":"given-name"}, "lifetimeSeconds":900 + })) + .unwrap(); + let grant = TaskGrant { + id: Uuid::new_v4(), + item_id: Uuid::new_v4(), + template, + authority: "statutory-caseworker-v1".into(), + source_issuer: "https://casework.test".into(), + approver: IssuerPrincipal { + issuer: "https://issuer.test".into(), + subject: "officer".into(), + }, + approver_profile: "staff".into(), + source_subject: SubjectRef { + source_id: "source".into(), + kind: "request".into(), + id: "request-1".into(), + }, + proposal: TaskProposalIdentity { + version: "1".into(), + integrity: None, + generation: "1".into(), + }, + subjects: BTreeMap::from([("given_name".into(), json!("Amina"))]), + approved_at: 100, + expires_at: 1_000, + }; + let response = authority.assertion(&grant, 200).unwrap(); + let encoded = response + .assertion + .split('.') + .nth(1) + .expect("the assertion has a payload"); + let payload: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()) + .expect("the assertion payload is JSON"); + assert_eq!( + payload["evidence_tags"], + json!(["fixture-agency", "benefits"]) + ); + assert_eq!( + payload["evidence_audience"], + "https://relying.test/procedure" + ); + assert!(payload.get("evidence_context").is_none()); + } +} + #[cfg(all(test, feature = "postgres-test"))] mod tests; diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index c7d5e3f877..b54b7dc736 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -1436,6 +1436,34 @@ ], "type": "object" }, + "EvidenceRequesterContext": { + "additionalProperties": false, + "properties": { + "audience": { + "format": "uri", + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "requesterTags": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9._-]*$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "requesterTags", + "audience" + ], + "type": "object" + }, "HistoryEntry": { "additionalProperties": false, "properties": { @@ -4192,6 +4220,9 @@ "minLength": 1, "type": "string" }, + "evidenceContext": { + "$ref": "#/components/schemas/EvidenceRequesterContext" + }, "expiresAt": { "minimum": 0, "type": "integer" @@ -4307,6 +4338,9 @@ }, "type": "array" }, + "evidenceContext": { + "$ref": "#/components/schemas/EvidenceRequesterContext" + }, "id": { "maxLength": 512, "minLength": 1, @@ -4417,6 +4451,9 @@ "minLength": 1, "type": "string" }, + "evidenceContext": { + "$ref": "#/components/schemas/EvidenceRequesterContext" + }, "id": { "maxLength": 512, "minLength": 1, diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index 6dc61d4664..1dd8028af3 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -1188,19 +1188,21 @@ def task_schemas() -> dict: subjects = {"type":"object", "maxProperties":32, "additionalProperties":{"type":["string","integer","boolean"]}} permission = obj({"collection":text, "operations":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":text}}, ["collection","operations"]) common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "scopes":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[\x21\x23-\x29\x2b-\x5b\x5d-\x7e]+$"}}, "purpose":text, "bounds":ref("TaskGrantBounds")} - preview = {"id":text, "version":text, "label":text, **common, "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} + evidence_context = obj({"requesterTags":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[a-z][a-z0-9._-]*$"}}, "audience":{"type":"string","format":"uri","minLength":1,"maxLength":4096}}, ["requesterTags","audience"]) + preview = {"id":text, "version":text, "label":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} template = {**preview, "eligibleTeams":array(text), "eligibleProfiles":array(text), "source":text, "itemKinds":array(text), "itemStates":{"type":"array","items":{"enum":["claimed","waiting_applicant","waiting_application"]}}} template["subjects"] = {"type":"object", "minProperties":1, "maxProperties":32, "additionalProperties":text} - view = {"id":uuid, "templateId":text, "templateVersion":text, **common, "expiresAt":number, "invalidated":{"type":"boolean"}} + view = {"id":uuid, "templateId":text, "templateVersion":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "expiresAt":number, "invalidated":{"type":"boolean"}} details = {"grantId":uuid, "authority":text, "sourceIssuer":text, "principal":text, "client":text, "resource":text, "purpose":text, "bounds":ref("TaskGrantBounds"), "subjects":subjects, "expiresAt":number} return { - "TaskTemplate":obj(template,list(template)), - "TaskTemplatePreview":obj(preview,list(preview)), + "EvidenceRequesterContext":evidence_context, + "TaskTemplate":obj(template,[name for name in template if name != "evidenceContext"]), + "TaskTemplatePreview":obj(preview,[name for name in preview if name != "evidenceContext"]), "TaskTemplatePreviews":obj({"itemRevision":number,"templates":array(ref("TaskTemplatePreview"))},["itemRevision","templates"]), "TaskPermission":permission, "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"])]}, "TaskApprovalRequest":obj({"templateId":text,"templateVersion":text},["templateId","templateVersion"]), - "TaskGrantView":obj(view,list(view)), + "TaskGrantView":obj(view,[name for name in view if name != "evidenceContext"]), "TaskGrantList":obj({"grants":{"type":"array","maxItems":128,"items":ref("TaskGrantView")}},["grants"]), "TaskGrantRevocation":obj({"id":uuid,"invalidated":{"type":"boolean"}},["id","invalidated"]), "TaskAssertionResponse":obj({"assertion":{"type":"string","description":"Sensitive short-lived credential. Do not log or persist."},"expiresAt":number,"grantExpiresAt":number},["assertion","expiresAt","grantExpiresAt"]), From d8b57da46a961f12193bbc7c043f8a0e338cc0c2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:12:10 +0700 Subject: [PATCH 036/120] docs(cli): review contextual grant commands Signed-off-by: Jeremi Joslin --- docs/site/src/data/cli-reference.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 1c4409f21d..bc53e08af1 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-13 reviewed_source_version: "0.30.0" -reviewed_catalog_sha256: 5024e1ba84323aec983e530918c7822571e2fecf2d0bc1f51af45817632a0296 -reviewed_content_sha256: 9e839054f084dad14a60879974b8d3ae57f650c22e2cf50d4ca92e1766907e7b +reviewed_catalog_sha256: 18ab8f98f216a3fe6b17cde0344767f178d6ef8b61d47d5f6efb138264bf2f1c +reviewed_content_sha256: 7c7ed6e4fc8037ba6a751dcfcf14e8190972271fc36db18d1d9687098fe72e09 From 3739895b494f94e62798b49adf5d54837ecdbc5a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:12:34 +0700 Subject: [PATCH 037/120] fix(tooling): preserve declared exchange attributes Signed-off-by: Jeremi Joslin --- crates/registry-thunderid-tooling/src/render.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/registry-thunderid-tooling/src/render.rs b/crates/registry-thunderid-tooling/src/render.rs index 600e000635..8882884119 100644 --- a/crates/registry-thunderid-tooling/src/render.rs +++ b/crates/registry-thunderid-tooling/src/render.rs @@ -318,13 +318,22 @@ pub fn render(description: &IssuerDescription) -> Result>(); + for attribute in &client.token_attributes { + if !exchange_attributes.contains(attribute) { + exchange_attributes.push(attribute.clone()); + } + } config["grantTypes"] = json!([ "client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange" ]); config["token"]["accessToken"]["userConfig"] = json!({ "validityPeriod": client.access_token_lifetime_seconds, - "attributes": crate::description::GRANT_ATTRIBUTES, + "attributes": exchange_attributes, }); } write_owner_only( @@ -558,7 +567,11 @@ mod tests { ); assert_eq!( config["token"]["accessToken"]["userConfig"]["attributes"], - json!(GRANT_ATTRIBUTES) + json!(GRANT_ATTRIBUTES + .iter() + .copied() + .chain(["synthetic_tag"]) + .collect::>()) ); assert_eq!( config["token"]["accessToken"]["clientConfig"]["attributes"], From cd3c8310828681788ef50feb279ca581a81da3fb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:13:40 +0700 Subject: [PATCH 038/120] test(evidence): cover contextual grant boundaries Signed-off-by: Jeremi Joslin --- .../tests/against_a_real_deployment.rs | 736 +++++++++++++++++- .../tests/security_contract_traceability.rs | 8 +- .../tests/selector_conformance.rs | 7 + .../acceptance-test-traceability.yaml | 18 + 4 files changed, 756 insertions(+), 13 deletions(-) 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 8c81d623bb..f0749250d5 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -26,14 +26,15 @@ use std::{ collections::{BTreeMap, HashMap}, error::Error, fs, + io::{Read, Write}, net::TcpListener, os::unix::fs::PermissionsExt as _, path::{Path, PathBuf}, sync::{ - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, }, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; @@ -41,18 +42,21 @@ use chrono::Utc; use p256::{ecdsa::SigningKey, elliptic_curve::rand_core::OsRng}; use registry_evidence::{runtime::EvidenceRuntime, server}; use registry_evidence_client::{ - AssuranceProfile, AudienceScopedRequest, ConceptForm, DefinitionCardinality, DefinitionKind, - EvidenceClient, EvidenceClientConfig, EvidenceClientError, EvidenceDefinitionsDocument, - EvidenceRequestSpec, EvidenceResponseFormat, OAuthErrorCode, PrivateKeyJwt, - PrivateKeyJwtConfig, PublicValue, SelectorField, SelectorValue, SelectorValueOrigin, - StaticToken, SubjectContinuity, SubjectExpectations, SubjectRequest, TokenError, TokenProvider, - TransportKind, VerificationError, VerifiedAudienceScopedEvidence, + AssuranceProfile, AudienceScopedRequest, BearerToken, ConceptForm, DefinitionCardinality, + DefinitionKind, EvidenceClient, EvidenceClientConfig, EvidenceClientError, + EvidenceDefinitionsDocument, EvidenceRequestSpec, EvidenceResponseFormat, OAuthErrorCode, + PrivateKeyJwt, PrivateKeyJwtConfig, PublicValue, SelectorField, SelectorValue, + SelectorValueOrigin, StaticToken, SubjectContinuity, SubjectExpectations, SubjectRequest, + TokenError, TokenProvider, TransportKind, VerificationError, VerifiedAudienceScopedEvidence, EVIDENCE_DEFINITIONS_SCHEMA_V1, }; use registry_platform_crypto::{sign, verify, PrivateJwk, PublicJwk}; use registry_thunderid_tooling::{ container::Session, - description::SessionIdentity, + description::{ + Action, ExchangeIssuer, MachineClient, Resource, ResourceServer, Role, SessionIdentity, + TokenExchangeClient, + }, local::{self, TypedLocalClient}, render, version::ThunderIdPin, @@ -80,8 +84,17 @@ const SOURCE_BEARER: &str = "source-bearer-canary"; /// signs its assertions with, and the key the authorization server signs the /// credentials it issues with. const CLIENT_ID: &str = "client-suite-relying-party"; +const TASK_CLIENT_ID: &str = "evidence-task-agent"; const CLIENT_KEY_ID: &str = "client-suite-client-key"; const ES256_CLIENT_KEY_ID: &str = "client-suite-client-key-es256"; +const TASK_CLIENT_KEY_ID: &str = "evidence-task-agent-key"; +const TASK_CLIENT_AGENT_ID: &str = "0197aaaa-0000-7000-8000-0000000000a1"; +const TASK_CLIENT_ROLE_ID: &str = "0197aaaa-0000-7000-8000-0000000000c1"; +const TASK_AUTHORITY_RESOURCE_ID: &str = "0197aaaa-0000-7000-8000-0000000000b1"; +const TASK_AUTHORITY_ISSUER_ID: &str = "0197aaaa-0000-7000-8000-0000000000d1"; +const TASK_AUTHORITY_PROFILE: &str = "statutory-caseworker-v1"; +const TASK_BOOTSTRAP_RESOURCE: &str = "urn:registry:evidence:fixture:task-authority"; +const TASK_BOOTSTRAP_SCOPE: &str = "grants:assert"; /// The shortest access token lifetime the authorization server accepts. The /// refresh margin case needs a margin wider than a whole credential's life. @@ -1114,6 +1127,7 @@ struct Deployment { base_url: Url, runtime: Arc, bundle_root: PathBuf, + audit_path: PathBuf, runtime_path: PathBuf, shutdown: Option>, server: tokio::task::JoinHandle>, @@ -1244,6 +1258,40 @@ async fn start_trusting_with_request_burst_and_jwks( external_issuer: Option<&str>, external_jwks_uri: Option<&str>, request_burst: u32, +) -> Deployment { + start_trusting_with_request_burst_jwks_and_task_authority( + source_answer, + external_issuer, + external_jwks_uri, + request_burst, + None, + ) + .await +} + +async fn start_task_grant_deployment( + source_answer: Value, + issuer: &StockTokenIssuer, + authority: &SyntheticAssertionAuthority, + request_burst: u32, +) -> Deployment { + let jwks_uri = issuer.jwks_uri(); + start_trusting_with_request_burst_jwks_and_task_authority( + source_answer, + Some(&issuer.origin), + Some(&jwks_uri), + request_burst, + Some(authority.issuer()), + ) + .await +} + +async fn start_trusting_with_request_burst_jwks_and_task_authority( + source_answer: Value, + external_issuer: Option<&str>, + external_jwks_uri: Option<&str>, + request_burst: u32, + task_authority: Option<&str>, ) -> Deployment { let source = start_mock_server().await; let auth_key = generate_key(AUTH_KEY_ID); @@ -1304,6 +1352,9 @@ async fn start_trusting_with_request_burst_and_jwks( &format!("http://127.0.0.1:{port}"), signing_key_id, ); + if let Some(task_authority) = task_authority { + rewrite_for_task_grant_profile(&bundle_root, task_authority); + } rewrite_request_burst(&bundle_root, request_burst); fs::remove_file( bundle_root @@ -1363,6 +1414,7 @@ async fn start_trusting_with_request_burst_and_jwks( base_url: Url::parse(&format!("http://127.0.0.1:{port}")).expect("the base URL parses"), runtime, bundle_root, + audit_path, runtime_path, shutdown: Some(shutdown_tx), server, @@ -1666,10 +1718,147 @@ async fn start_token_issuer() -> TokenIssuer { } } +/// A controlled assertion signer used only by the ignored stock-container gate. +/// +/// The HTTP surface publishes generated public keys to the container. Grant +/// approval and revocation stay explicit test state: this fixture is not a +/// production authority implementation and is never presented as Casework. +struct SyntheticAssertionAuthority { + issuer: String, + signing_key: PrivateJwk, + active: Arc, + stop: Arc, + thread: Option>, +} + +impl SyntheticAssertionAuthority { + fn start() -> Self { + let listener = + TcpListener::bind(("0.0.0.0", 0)).expect("the synthetic authority JWKS listener binds"); + let port = listener + .local_addr() + .expect("the synthetic authority address is available") + .port(); + listener + .set_nonblocking(true) + .expect("the synthetic authority listener is nonblocking"); + let issuer = format!("http://host.docker.internal:{port}/authority"); + let signing_key = generate_es256_key("synthetic-evidence-authority-key"); + let public_jwks = json!({"keys":[signing_key.public()]}).to_string(); + let active = Arc::new(AtomicBool::new(true)); + let stop = Arc::new(AtomicBool::new(false)); + let running = Arc::clone(&stop); + let thread = std::thread::spawn(move || { + while !running.load(Ordering::Relaxed) { + if let Ok((mut stream, _)) = listener.accept() { + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut request = [0u8; 4096]; + let length = stream.read(&mut request).unwrap_or(0); + if String::from_utf8_lossy(&request[..length]) + .starts_with("GET /authority/jwks ") + { + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + public_jwks.len(), + public_jwks + ); + } else { + let _ = stream.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + } else { + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + Self { + issuer, + signing_key, + active, + stop, + thread: Some(thread), + } + } + + fn issuer(&self) -> &str { + &self.issuer + } + + fn revoke(&self) { + self.active.store(false, Ordering::SeqCst); + } + + fn assertion( + &self, + token_issuer: &str, + grant_id: &str, + subject: &str, + identity: Value, + grant_expires_at: u64, + ) -> Option { + if !self.active.load(Ordering::SeqCst) { + return None; + } + let now = unix_seconds(); + let claims = json!({ + "iss": self.issuer, + "aud": token_issuer, + "sub": subject, + "iat": now, + "exp": (now + 60).min(grant_expires_at), + "jti": grant_id, + "scope": "evidence:invoke", + "registry_actor_kind": "agent", + "registry_grant_id": grant_id, + "registry_grant_authority": TASK_AUTHORITY_PROFILE, + "registry_grant_client": TASK_CLIENT_ID, + "registry_grant_resource": TOKEN_AUDIENCE, + "registry_purpose": "fixture-eligibility", + "registry_grant_exp": grant_expires_at, + "registry_grant_bounds": {"type":"evidence", "requirement":REQUIREMENT}, + "identity": identity, + "evidence_tags": [CONFIGURED_TAG], + "evidence_audience": RELYING_AUDIENCE, + }); + let header = json!({ + "alg": "ES256", + "kid": "synthetic-evidence-authority-key", + "typ": "JWT" + }); + let input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header.to_string()), + URL_SAFE_NO_PAD.encode(claims.to_string()), + ); + let signature = sign(input.as_bytes(), &self.signing_key) + .expect("the synthetic authority signs its assertion"); + Some(format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature))) + } +} + +impl Drop for SyntheticAssertionAuthority { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("the test clock is after the Unix epoch") + .as_secs() +} + struct StockTokenIssuer { origin: String, token_endpoint: Url, client_key: PrivateJwk, + task_client_key: Option, label: String, id: String, port: u16, @@ -1699,6 +1888,42 @@ impl StockTokenIssuer { fn jwks_uri(&self) -> String { format!("{}/oauth2/jwks", self.origin) } + + fn task_provider(&self) -> Arc { + Arc::new( + PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + self.token_endpoint.clone(), + TASK_CLIENT_ID, + self.task_client_key + .clone() + .expect("the task issuer carries its registered client key"), + ) + .with_audience(self.origin.clone()) + .with_resource(TOKEN_AUDIENCE) + .with_scopes(["evidence:invoke".to_owned()]), + ) + .expect("the stock task exchange provider is valid"), + ) + } + + fn task_bootstrap_provider(&self) -> Arc { + Arc::new( + PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + self.token_endpoint.clone(), + TASK_CLIENT_ID, + self.task_client_key + .clone() + .expect("the task issuer carries its registered client key"), + ) + .with_audience(self.origin.clone()) + .with_resource(TASK_BOOTSTRAP_RESOURCE) + .with_scopes([TASK_BOOTSTRAP_SCOPE.to_owned()]), + ) + .expect("the stock task bootstrap provider is valid"), + ) + } } impl Drop for StockTokenIssuer { @@ -1725,6 +1950,10 @@ fn installed_or_env(variable: &str, binary: &str) -> PathBuf { } fn start_stock_token_issuer() -> StockTokenIssuer { + start_stock_token_issuer_with_authority(None) +} + +fn start_stock_token_issuer_with_authority(authority_issuer: Option<&str>) -> StockTokenIssuer { let reservation = TcpListener::bind("127.0.0.1:0").expect("reserve stock issuer port"); let port = reservation .local_addr() @@ -1741,7 +1970,7 @@ fn start_stock_token_issuer() -> StockTokenIssuer { let id = format!("evidence-client-session-{port}"); let client_key = generate_es256_key(ES256_CLIENT_KEY_ID); let public_jwks = json!({"keys":[client_key.public()]}).to_string(); - let description = local::typed_local_description( + let mut description = local::typed_local_description( SessionIdentity { label: label.clone(), id: id.clone(), @@ -1762,6 +1991,66 @@ fn start_stock_token_issuer() -> StockTokenIssuer { }], ) .expect("the stock issuer description is valid"); + let task_client_key = authority_issuer.map(|authority_issuer| { + let key = generate_es256_key(TASK_CLIENT_KEY_ID); + description.resource_servers.push(ResourceServer { + id: TASK_AUTHORITY_RESOURCE_ID.to_owned(), + name: "Synthetic task authority".to_owned(), + identifier: TASK_BOOTSTRAP_RESOURCE.to_owned(), + description: "Test-only assertion acquisition boundary".to_owned(), + resources: vec![Resource { + name: "grants".to_owned(), + handle: "grants".to_owned(), + parent: None, + description: "Test-only grant assertion access".to_owned(), + actions: vec![Action { + name: "assert".to_owned(), + handle: "assert".to_owned(), + description: "Request one approved assertion".to_owned(), + }], + }], + }); + description.roles.push(Role { + id: TASK_CLIENT_ROLE_ID.to_owned(), + name: "Synthetic task bootstrap".to_owned(), + description: "Assertion acquisition only".to_owned(), + permissions: vec![( + TASK_AUTHORITY_RESOURCE_ID.to_owned(), + vec![TASK_BOOTSTRAP_SCOPE.to_owned()], + )], + assigned_agents: vec![TASK_CLIENT_AGENT_ID.to_owned()], + }); + description.machine_clients.push(MachineClient { + agent_id: TASK_CLIENT_AGENT_ID.to_owned(), + name: "Synthetic Evidence task agent".to_owned(), + description: "Test-only institutional task client".to_owned(), + client_id: TASK_CLIENT_ID.to_owned(), + public_jwks: json!({"keys":[key.public()]}).to_string(), + // These declare the native attribute names and types. On token + // exchange ThunderID filters the signed assertion through the + // userConfig allowlist; it does not substitute these static values. + attributes: BTreeMap::from([ + ("evidence_tags".to_owned(), json!([CONFIGURED_TAG])), + ("evidence_audience".to_owned(), json!(RELYING_AUDIENCE)), + ]), + token_attributes: vec!["evidence_tags".to_owned(), "evidence_audience".to_owned()], + access_token_lifetime_seconds: 300, + token_exchange: Some(TokenExchangeClient { + assertion_resource_server_id: TASK_AUTHORITY_RESOURCE_ID.to_owned(), + assertion_scope: TASK_BOOTSTRAP_SCOPE.to_owned(), + }), + }); + description.exchange_issuers.push(ExchangeIssuer { + id: TASK_AUTHORITY_ISSUER_ID.to_owned(), + name: "Synthetic assertion authority".to_owned(), + issuer: authority_issuer.to_owned(), + jwks_endpoint: format!("{authority_issuer}/jwks"), + }); + key + }); + description + .validate() + .expect("the stock issuer task exchange description is valid"); render::render(&description).expect("the stock issuer resources render"); let pin = ThunderIdPin::load().expect("the maintained stock issuer pin loads"); let docker = installed_or_env("DOCKER_BIN", "docker"); @@ -1778,6 +2067,7 @@ fn start_stock_token_issuer() -> StockTokenIssuer { origin, token_endpoint, client_key, + task_client_key, label, id, port, @@ -1824,6 +2114,399 @@ async fn stock_issuer_token_carries_a_verified_evidence_request() { assert_eq!(accepted.evidence().supports_requirement, REQUIREMENT); } +/// The phase-one task matrix at the resource boundary. A generated test +/// authority signs assertions, pinned stock ThunderID 1.0.1 exchanges them, +/// and the real Evidence HTTP service enforces their immutable context. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "exact gate: starts the pinned stock issuer and real Evidence runtime"] +async fn stock_issuer_task_grants_are_subject_bound_at_the_evidence_boundary() { + let authority = SyntheticAssertionAuthority::start(); + let authority_issuer = authority.issuer().to_owned(); + let issuer = tokio::task::spawn_blocking(move || { + start_stock_token_issuer_with_authority(Some(&authority_issuer)) + }) + .await + .expect("the stock task issuer startup task completes"); + let deployment = + start_task_grant_deployment(resolved_source_answer(), &issuer, &authority, 40).await; + let exchange = issuer.task_provider(); + let expires_at = unix_seconds() + 120; + + let first_assertion = authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000001", + "institutional-agent", + grant_identity("Amina", "Diallo", "2000-01-01"), + expires_at, + ) + .expect("the first task remains approved"); + let second_assertion = authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000002", + "institutional-agent", + grant_identity("Adaeze", "Okafor", "1990-07-11"), + expires_at, + ) + .expect("the second task remains approved"); + let (first_token, second_token) = tokio::join!( + exchange.exchange(&first_assertion), + exchange.exchange(&second_assertion), + ); + let first_token = bearer_text(&first_token.expect("the first grant exchanges")); + let second_token = bearer_text(&second_token.expect("the second grant exchanges")); + assert_stock_task_context(&first_token, authority.issuer(), expires_at); + assert_stock_task_context(&second_token, authority.issuer(), expires_at); + let first_client = deployment.client(&first_token); + let second_client = deployment.client(&second_token); + let definitions = first_client + .discover() + .await + .expect("the task grant discovers its bounded definition"); + let first_request = first_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the first grant request prepares"); + let second_request = second_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the second grant request prepares"); + let (first, second) = tokio::join!( + first_client.request_and_verify(&first_request), + second_client.request_and_verify(&second_request), + ); + let first = first.expect("the first subject grant answers"); + let second = second.expect("the second subject grant answers"); + assert_ne!( + first.evidence().subjects[0].binding, + second.evidence().subjects[0].binding, + "concurrent task grants for different people keep different subject bindings" + ); + let distinct_subject_pseudonyms = released_grant_pseudonyms(&deployment.audit_path); + assert_eq!(distinct_subject_pseudonyms.len(), 2); + + let same_identity = grant_identity("Amina", "Diallo", "2000-01-01"); + let equivalent_a = authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000003", + "institutional-agent", + same_identity.clone(), + expires_at, + ) + .expect("the first equivalent task remains approved"); + let equivalent_b = authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000004", + "institutional-agent", + same_identity, + expires_at, + ) + .expect("the second equivalent task remains approved"); + let (equivalent_a, equivalent_b) = tokio::join!( + exchange.exchange(&equivalent_a), + exchange.exchange(&equivalent_b), + ); + let equivalent_a = bearer_text(&equivalent_a.expect("the first equivalent grant exchanges")); + let equivalent_b = bearer_text(&equivalent_b.expect("the second equivalent grant exchanges")); + let equivalent_a_client = deployment.client(&equivalent_a); + let equivalent_b_client = deployment.client(&equivalent_b); + let equivalent_a_request = equivalent_a_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the first equivalent request prepares"); + let equivalent_b_request = equivalent_b_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the second equivalent request prepares"); + let (equivalent_a_answer, equivalent_b_answer) = tokio::join!( + equivalent_a_client.request_and_verify(&equivalent_a_request), + equivalent_b_client.request_and_verify(&equivalent_b_request), + ); + assert_eq!( + equivalent_a_answer + .expect("the first equivalent grant answers") + .evidence() + .subjects[0] + .binding, + equivalent_b_answer + .expect("the second equivalent grant answers") + .evidence() + .subjects[0] + .binding, + "identical bounds may answer the same subject" + ); + let all_grant_pseudonyms = released_grant_pseudonyms(&deployment.audit_path); + assert_eq!(all_grant_pseudonyms.len(), 4); + assert!(distinct_subject_pseudonyms.is_subset(&all_grant_pseudonyms)); + + let ordinary_token = bearer_text( + &issuer + .provider() + .bearer_token() + .await + .expect("the ordinary registered service obtains its token"), + ); + let ordinary_client = deployment.client(&ordinary_token); + let ordinary_request = ordinary_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the task-shaped request prepares independently of its credential"); + assert_denied( + ordinary_client.request_and_verify(&ordinary_request).await, + 403, + "evidence.denied", + ); + + let mut retargeted = serde_json::from_slice::( + &deployment + .client(&first_token) + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the retarget baseline prepares") + .request_json() + .expect("the retarget baseline serializes"), + ) + .expect("the request body parses"); + retargeted["subjects"][0]["selector"]["values"] = json!({ + "given_name":"Retargeted", + "family_name":"Subject", + "birth_date":"1980-01-01" + }); + let retarget_response = reqwest::Client::new() + .post( + deployment + .base_url + .join("v1/evidence") + .expect("the Evidence endpoint resolves"), + ) + .header("authorization", format!("Bearer {first_token}")) + .header("accept", "application/jose+json") + .header("content-type", "application/json") + .json(&retargeted) + .send() + .await + .expect("the body-retarget request reaches Evidence"); + assert_eq!(retarget_response.status(), 400); + let retarget_problem: Value = retarget_response + .json() + .await + .expect("the body-retarget refusal is JSON"); + assert_eq!(retarget_problem["code"], "request.selector_invalid"); + + let short_deadline = unix_seconds() + 4; + let short_assertion = authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000005", + "institutional-agent", + grant_identity("Binta", "Diallo", "1985-03-04"), + short_deadline, + ) + .expect("the short task remains approved before revocation"); + let short_token = bearer_text( + &exchange + .exchange(&short_assertion) + .await + .expect("the short grant exchanges"), + ); + let reexchanged = bearer_text( + &exchange + .exchange(&short_token) + .await + .expect("the task token re-exchanges before its deadline"), + ); + assert_eq!( + jwt_payload(&reexchanged)["registry_grant_exp"], + short_deadline, + "re-exchange preserves the authority's immutable deadline" + ); + authority.revoke(); + assert!(authority + .assertion( + &issuer.origin, + "01980000-0000-7000-8000-000000000006", + "institutional-agent", + grant_identity("New", "Subject", "1991-02-03"), + unix_seconds() + 60, + ) + .is_none()); + let window_client = deployment.client(&reexchanged); + let window_request = window_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the retained-window request prepares"); + window_client + .request_and_verify(&window_request) + .await + .expect("an already issued token remains readable inside its bounded window"); + tokio::time::sleep(Duration::from_secs(5)).await; + let after_deadline = bearer_text( + &exchange + .exchange(&reexchanged) + .await + .expect("the stock issuer may re-exchange a still-live JWT after its grant deadline"), + ); + assert_eq!( + jwt_payload(&after_deadline)["registry_grant_exp"], + short_deadline, + "an exchange after the grant deadline still cannot extend authority" + ); + let expired_client = deployment.client(&after_deadline); + let expired_request = expired_client + .prepare(grant_spec( + &definitions, + SubjectExpectations::AcceptFirstUse, + )) + .expect("the expired-window request prepares"); + assert_denied( + expired_client.request_and_verify(&expired_request).await, + 403, + "evidence.denied", + ); + + let bootstrap = issuer.task_bootstrap_provider(); + let bootstrap_token = bearer_text( + &bootstrap + .bearer_token() + .await + .expect("the exchange-only client obtains its bootstrap token"), + ); + assert!( + jwt_payload(&bootstrap_token) + .get("registry_grant_id") + .is_none(), + "bootstrap acquisition cannot manufacture a task grant" + ); +} + +fn bearer_text(token: &BearerToken) -> String { + token + .authorization_header_value() + .to_str() + .expect("a bearer credential is visible ASCII") + .strip_prefix("Bearer ") + .expect("the shared wrapper emits the Bearer scheme") + .to_owned() +} + +fn jwt_payload(token: &str) -> Value { + let payload = token + .split('.') + .nth(1) + .expect("the stock issuer returns a compact JWT"); + serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload) + .expect("the stock token payload is base64url"), + ) + .expect("the stock token payload is JSON") +} + +fn assert_stock_task_context(token: &str, authority_issuer: &str, grant_expires_at: u64) { + let claims = jwt_payload(token); + assert!( + claims["aud"] == TOKEN_AUDIENCE, + "the exchanged token has the Evidence audience" + ); + assert!( + claims["client_id"] == TASK_CLIENT_ID, + "the exchanged token retains the verified client" + ); + assert!( + claims["registry_actor_kind"] == "agent", + "the exchanged token retains the agent kind" + ); + assert!( + claims["registry_grant_client"] == TASK_CLIENT_ID, + "the exchanged token retains the original grant client" + ); + assert!( + claims["registry_grant_resource"] == TOKEN_AUDIENCE, + "the exchanged token retains the original grant resource" + ); + assert!( + claims["registry_grant_source_issuer"] == authority_issuer, + "the exchanged token uses verified issuer provenance" + ); + assert!( + claims["registry_grant_exp"] == grant_expires_at, + "the exchanged token retains the grant deadline" + ); + assert!( + claims["evidence_tags"] == json!([CONFIGURED_TAG]), + "the exchanged token retains requester tags" + ); + assert!( + claims["evidence_audience"] == RELYING_AUDIENCE, + "the exchanged token retains the relying-party audience" + ); +} + +fn grant_identity(given_name: &str, family_name: &str, birth_date: &str) -> Value { + json!({ + "given_name": given_name, + "family_name": family_name, + "birth_date": birth_date, + }) +} + +fn grant_spec( + definitions: &EvidenceDefinitionsDocument, + subject_expectations: SubjectExpectations, +) -> EvidenceRequestSpec { + let mut spec = spec(definitions, "unused-grant-selector", subject_expectations); + for subject in &mut spec.subjects { + subject.selector_values = None; + } + spec +} + +fn assert_denied( + result: Result, + expected_status: u16, + expected_code: &str, +) { + let (status, code) = match result { + Err(EvidenceClientError::Denied { status, code, .. }) => (status, code), + Err(other) => panic!("the refusal maps onto the denied failure, got {other}"), + Ok(_) => panic!("the bounded task request is refused"), + }; + assert_eq!(status, expected_status); + assert_eq!(code, expected_code); +} + +fn released_grant_pseudonyms(path: &Path) -> std::collections::BTreeSet { + fs::read_to_string(path) + .expect("the durable task audit is readable") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|event| event["record"]["phase"] == "disclosure-release") + .filter_map(|event| { + event + .pointer("/record/authority/grantPseudonym") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .collect() +} + fn fixture_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../products/evidence/fixtures/acceptance/adult-status") @@ -1904,6 +2587,39 @@ fn rewrite_for_local_profile( regenerate_discovery_description(bundle_root); } +/// Turn the copied one-subject acceptance definition into the task-bound form. +/// The tracked source, scripts, requirement, signing and disclosure policy stay +/// byte-for-byte the same; only authentication and selector authority change. +fn rewrite_for_task_grant_profile(bundle_root: &Path, authority_issuer: &str) { + let configuration_path = bundle_root.join("evidence.yaml"); + let mut document = + fs::read_to_string(&configuration_path).expect("the staged configuration is readable"); + replace_exact( + &mut document, + " principalClaim: sub\n requesterTagsClaim: evidence_tags", + &format!( + " principalClaim: sub\n allowedClients: [{CLIENT_ID}, {TASK_CLIENT_ID}]\n requesterTagsClaim: evidence_tags" + ), + 1, + ); + replace_exact( + &mut document, + " statutory-caseworker-v1:\n kind: statutory", + &format!( + " statutory-caseworker-v1:\n requesterClients: [{TASK_CLIENT_ID}]\n grantSourceIssuer: {authority_issuer}\n kind: statutory" + ), + 1, + ); + replace_exact( + &mut document, + " subjects: [{role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}]", + " subjects:\n - role: subject\n selectorProfile: person-demographics-v1\n valueOrigin: authenticated-grant\n valueClaims: {given_name: identity.given_name, family_name: identity.family_name, birth_date: identity.birth_date}", + 1, + ); + fs::write(&configuration_path, document).expect("the task-grant configuration is written"); + regenerate_discovery_description(bundle_root); +} + fn regenerate_discovery_description(bundle_root: &Path) { let config = registry_evidence::config::EvidenceConfig::parse_yaml( &fs::read(bundle_root.join("evidence.yaml")) diff --git a/crates/registry-evidence/tests/security_contract_traceability.rs b/crates/registry-evidence/tests/security_contract_traceability.rs index c3bce9dbb5..1d4738ca96 100644 --- a/crates/registry-evidence/tests/security_contract_traceability.rs +++ b/crates/registry-evidence/tests/security_contract_traceability.rs @@ -337,7 +337,8 @@ fn every_frozen_profile_negative_is_bound_to_a_mapped_security_negative() { fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &TestReference) { // Evidence security invariants may be implemented by the runtime, its // relying-party client, the portable verifier, the narrowly shared - // platform primitives they use, or the OpenID4VCI delivery front end, + // platform primitives they use, the governed Casework authority, or the + // OpenID4VCI delivery front end, // which owns the wallet-facing boundary the runtime deliberately does not // speak. The front end is a permitted implementer of its own delivery // negatives only; a delivery negative proven in a shared primitive rather @@ -347,6 +348,7 @@ fn assert_reference_is_an_executable_test(root: &Path, entry_id: &str, test: &Te "crates/registry-evidence-client/", "crates/registry-evidence-verifier/", "crates/registry-evidence-oid4vci/", + "crates/registry-casework/", "crates/registry-platform-audit/", "crates/registry-platform-config/", "crates/registry-platform-crypto/", @@ -395,7 +397,7 @@ fn every_acceptance_row_is_bound_to_an_executable_test() { "registry.evidence.acceptance-test-traceability/v1" ); - let expected = (1..=84) + let expected = (1..=85) .map(|row| format!("acceptance-row-{row:02}")) .collect::>(); let mapped = traceability @@ -405,7 +407,7 @@ fn every_acceptance_row_is_bound_to_an_executable_test() { .collect::>(); assert_eq!( mapped, expected, - "acceptance row mapping is not the 84 required rows in order" + "acceptance row mapping is not the 85 required rows in order" ); for entry in &traceability.entries { diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index f0c93d087c..4af05a7e54 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -965,6 +965,13 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { async fn task_grant_context_is_bound_before_selector_or_source_access() { let service = prepare_service(false).await; let request = grant_request(None); + assert_authorization_error( + &service, + &access_token(json!({})), + &request, + AuthorizationError::Unauthorized, + ) + .await; let subject = json!({ "given_name": "Adaeze", "family_name": "Okafor", diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index fd08bc6b9b..395864b385 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -585,3 +585,21 @@ entries: - {file: crates/registry-evidence/src/runtime_tests.rs, name: graceful_shutdown_waits_for_admitted_request_batch_and_terminal_audit} - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_batch_later_dependency_failure_has_one_value_free_abort_and_no_release} - {file: crates/registry-evidence/src/runtime_tests.rs, name: request_batch_response_above_one_mib_releases_no_partial_envelope} + - id: acceptance-row-85 + summary: Pinned stock ThunderID exchanges concurrent task grants into Evidence-bound tokens; different subject bounds remain separate, equivalent bounds may answer the same subject with distinct grant audit correlation, an ordinary service token and body retargeting are refused, and an issued token remains usable after authority revocation only until its immutable grant deadline. + tests: + - {file: crates/registry-evidence-client/tests/against_a_real_deployment.rs, name: stock_issuer_task_grants_are_subject_bound_at_the_evidence_boundary} + - {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes} + - {file: crates/registry-evidence/tests/selector_conformance.rs, name: task_grant_context_is_bound_before_selector_or_source_access} + - {file: crates/registry-evidence/src/config.rs, name: task_grant_profiles_bind_trusted_source_and_verified_client} + - {file: crates/registry-evidence/src/auth.rs, name: purpose_only_service_token_is_not_a_task_grant} + note: >- + The controlled signer in the stock-container test is a synthetic assertion + authority, not Casework. The Casework native composition test separately + covers officer preview and approval, signed requester context, native + exchange, and a successful real Evidence request. Issuance-time wrong-key, expired-assertion, + unregistered-authority, unreachable-authority, provenance-overwrite, and + bootstrap-isolation cases remain in the pinned stock issuer Gate0 at + crates/registry-thunderid-tooling/examples/contextual-exchange.rs; this row + composes the resulting token with the Evidence resource boundary instead + of duplicating those issuer tests. From ca6b23bd937cdfd864df9f043a00a2eff5273a56 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:14:31 +0700 Subject: [PATCH 039/120] test(casework): compose Evidence task grants Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + crates/registry-casework/Cargo.toml | 1 + .../src/task_grants/native_exchange_tests.rs | 470 +++++++++++++++++- crates/registry-caseworkctl/Cargo.toml | 1 + 4 files changed, 462 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd3874b46c..1b2c3986b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3532,6 +3532,7 @@ dependencies = [ "registry-casework-breg", "registry-casework-client", "registry-casework-core", + "registry-evidence", "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", @@ -3663,6 +3664,7 @@ dependencies = [ "p256", "rcgen", "registry-casework", + "registry-casework-breg", "registry-casework-core", "registry-platform-buildinfo", "registry-platform-config", diff --git a/crates/registry-casework/Cargo.toml b/crates/registry-casework/Cargo.toml index f7a79a7047..e057297a45 100644 --- a/crates/registry-casework/Cargo.toml +++ b/crates/registry-casework/Cargo.toml @@ -59,6 +59,7 @@ zeroize.workspace = true [dev-dependencies] registry-breg = { workspace = true, features = ["postgres-test"] } +registry-evidence.workspace = true registry-thunderid-tooling.workspace = true reqwest.workspace = true jsonschema.workspace = true diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index e39a74d5b9..31242039cf 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -1,8 +1,8 @@ -//! Actual Casework approval -> native RFC 8693 exchange -> BREG PostgreSQL mutation. +//! Actual Casework approval -> native RFC 8693 exchange -> Evidence and BREG resources. //! Credentials and protected response bodies stay in memory and never enter logs or argv. //! Set the two disposable database variables named by the ignore reason, then run //! `cargo test --locked -p registry-casework --features postgres-test --lib -//! approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_writes -- --ignored`. +//! approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes -- --ignored`. use super::*; use async_trait::async_trait; use axum::{ @@ -12,16 +12,32 @@ use axum::{ }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use registry_casework_core::*; +use registry_evidence::{runtime::EvidenceRuntime, server as evidence_server}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; use registry_thunderid_tooling::{container::Session, description::*, local, render}; -use std::{collections::BTreeMap, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; +use std::{ + collections::BTreeMap, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Arc, +}; +use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, +}; #[path = "native_resource.rs"] mod resource; const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; const BREG_RESOURCE: &str = "urn:breg:task-test"; +const EVIDENCE_RESOURCE: &str = "urn:registry:evidence:fixture"; +const EVIDENCE_REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; +const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; +const EVIDENCE_TAG: &str = "fixture-agency"; const AUTHORITY: &str = "https://casework.example"; +const EVIDENCE_SIGNING_KEY: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; fn binding() -> SourceBinding { SourceBinding { @@ -80,10 +96,24 @@ impl SourceAdapter for Source { fields: &[String], _: Option<(&str, EphemeralCredential<'_>)>, ) -> Result { - assert_eq!(fields, &["tenant"]); + let values = match fields { + [field] if field == "tenant" => BTreeMap::from([("tenant".into(), json!("tenant-a"))]), + [birth_date, family_name, given_name] + if birth_date == "birth_date" + && family_name == "family_name" + && given_name == "given_name" => + { + BTreeMap::from([ + ("birth_date".into(), json!("2000-01-01")), + ("family_name".into(), json!("Diallo")), + ("given_name".into(), json!("Amina")), + ]) + } + _ => panic!("unexpected governed task fields: {fields:?}"), + }; Ok(TaskSubjectContext { binding: binding(), - values: std::collections::BTreeMap::from([("tenant".into(), json!("tenant-a"))]), + values, }) } async fn prepare_action( @@ -149,11 +179,247 @@ impl Drop for Issuer { let _ = local::stop(&self.session(), Path::new("docker")); } } + +struct EvidenceDeployment { + base_url: String, + _runtime: Arc, + server: tokio::task::JoinHandle>, + bundle_root: PathBuf, + runtime_path: PathBuf, + _source: MockServer, +} + +impl Drop for EvidenceDeployment { + fn drop(&mut self) { + self.server.abort(); + let _ = fs::set_permissions(&self.runtime_path, fs::Permissions::from_mode(0o644)); + unseal(&self.bundle_root); + } +} + +async fn start_evidence(root: &Path, issuer: &Issuer) -> EvidenceDeployment { + let source = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/facts")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "total": 1, + "date_of_birth": "2000-01-01" + }))) + .mount(&source) + .await; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let evidence_root = root.join("evidence-deployment"); + let bundle_root = evidence_root.join("bundle"); + let secret_root = evidence_root.join("secrets"); + let runtime_path = evidence_root.join("runtime.yaml"); + let audit_path = evidence_root.join("audit.jsonl"); + fs::create_dir_all(&bundle_root).unwrap(); + fs::create_dir_all(&secret_root).unwrap(); + fs::set_permissions(&secret_root, fs::Permissions::from_mode(0o700)).unwrap(); + copy_tree( + &Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/evidence/fixtures/acceptance/adult-status"), + &bundle_root, + ); + rewrite_evidence_fixture( + &bundle_root, + &source.uri(), + &issuer.url(), + &format!("{}/oauth2/jwks", issuer.url()), + &format!("http://127.0.0.1:{port}"), + ); + write_secret( + &secret_root, + "audit-hash-key", + "casework-evidence-audit-secret-32-bytes", + ); + write_secret( + &secret_root, + "subject-binding-key", + "casework-evidence-binding-secret-32-bytes", + ); + write_secret(&secret_root, "signing-key", EVIDENCE_SIGNING_KEY); + write_secret(&secret_root, "source-a-token", "source-fixture-token"); + fs::write( + &runtime_path, + format!( + r#"version: 1 +bundleDirectory: {bundle} +listener: + bindHost: 127.0.0.1 + port: {port} + tlsTermination: operator-controlled-upstream + trustProxyIdentityHeaders: false + maximumRequestBytes: 65536 + maximumConcurrentRequests: 64 + requestTimeoutMilliseconds: 10000 + shutdownGraceMilliseconds: 30000 +secretProviders: + file: + root: {secrets} +signer: + kind: local-jwk + privateKeyRef: secret:file/signing-key +auditStorage: + path: {audit} + maximumFileBytes: 10485760 +outboundTls: + systemRoots: true + trustProfiles: {{}} +"#, + bundle = bundle_root.display(), + secrets = secret_root.display(), + audit = audit_path.display(), + ), + ) + .unwrap(); + fs::set_permissions(&runtime_path, fs::Permissions::from_mode(0o444)).unwrap(); + seal(&bundle_root); + let runtime = Arc::new(EvidenceRuntime::initialize(&runtime_path).await.unwrap()); + let served = Arc::clone(&runtime); + let server = + tokio::spawn( + async move { evidence_server::serve(served, std::future::pending::<()>()).await }, + ); + let base_url = format!("http://127.0.0.1:{port}"); + let probe = reqwest::Client::builder().no_proxy().build().unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + if probe + .get(format!("{base_url}/ready")) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + EvidenceDeployment { + base_url, + _runtime: runtime, + server, + bundle_root, + runtime_path, + _source: source, + } +} + +fn rewrite_evidence_fixture( + bundle_root: &Path, + source_origin: &str, + issuer_origin: &str, + issuer_jwks_uri: &str, + public_origin: &str, +) { + let path = bundle_root.join("evidence.yaml"); + let mut document = fs::read_to_string(&path).unwrap(); + for (from, to) in [ + ("assuranceProfile: evidence-grade", "assuranceProfile: local"), + ( + "baseUrl: https://source.invalid", + &format!("baseUrl: {source_origin}"), + ), + ( + "publicOrigin: https://evidence.invalid", + &format!("publicOrigin: {public_origin}"), + ), + ( + "issuer: https://identity.invalid", + &format!("issuer: {issuer_origin}"), + ), + ( + "audiences: [evidence-fixture]", + &format!("audiences: [{EVIDENCE_RESOURCE}]"), + ), + ( + "jwksUri: https://identity.invalid/.well-known/jwks.json", + &format!("jwksUri: {issuer_jwks_uri}"), + ), + ("algorithms: [ES256]", "algorithms: [RS256]"), + ( + " principalClaim: sub\n requesterTagsClaim: evidence_tags", + " principalClaim: sub\n allowedClients: [evidence-task-agent]\n requesterTagsClaim: evidence_tags", + ), + ( + " statutory-caseworker-v1:\n kind: statutory", + " casework:\n requesterClients: [evidence-task-agent]\n grantSourceIssuer: https://casework.example\n kind: statutory", + ), + ( + " subjects: [{role: subject, selectorProfile: person-demographics-v1, valueOrigin: request}]", + " subjects:\n - role: subject\n selectorProfile: person-demographics-v1\n valueOrigin: authenticated-grant\n valueClaims: {given_name: identity.given_name, family_name: identity.family_name, birth_date: identity.birth_date}", + ), + ] { + assert_eq!(document.matches(from).count(), 1, "fixture drift for {from}"); + document = document.replace(from, to); + } + fs::write(&path, document).unwrap(); + let config = + registry_evidence::config::EvidenceConfig::parse_yaml(&fs::read(&path).unwrap()).unwrap(); + let discovery = registry_evidence::discovery::render(&config) + .unwrap() + .unwrap(); + fs::write(bundle_root.join("catalog.jsonld"), discovery).unwrap(); +} + +fn write_secret(root: &Path, name: &str, value: &str) { + let path = root.join(name); + fs::write(&path, value).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); +} + +fn copy_tree(source: &Path, target: &Path) { + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let destination = target.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + fs::create_dir(&destination).unwrap(); + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).unwrap(); + } + } +} + +fn seal(root: &Path) { + for entry in fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + let child = entry.path(); + if child.is_dir() { + seal(&child); + } else { + fs::set_permissions(child, fs::Permissions::from_mode(0o444)).unwrap(); + } + } + fs::set_permissions(root, fs::Permissions::from_mode(0o555)).unwrap(); +} + +fn unseal(root: &Path) { + let _ = fs::set_permissions(root, fs::Permissions::from_mode(0o755)); + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let child = entry.path(); + if child.is_dir() { + unseal(&child); + } else { + let _ = fs::set_permissions(child, fs::Permissions::from_mode(0o644)); + } + } +} fn start_issuer( root: &Path, casework_port: u16, human: ®istry_platform_crypto::PrivateJwk, agent: ®istry_platform_crypto::PrivateJwk, + evidence_agent: ®istry_platform_crypto::PrivateJwk, status: ®istry_platform_crypto::PrivateJwk, seed: ®istry_platform_crypto::PrivateJwk, ) -> Issuer { @@ -178,6 +444,12 @@ fn start_issuer( Some("agent"), &["casework:grants:assert"], ), + client( + "evidence-task-agent", + evidence_agent, + Some("agent"), + &["casework:grants:assert"], + ), client( "breg-status", status, @@ -212,15 +484,59 @@ fn start_issuer( description.schema_attributes.sort(); description.schema_attributes.dedup(); let authority_server = description.resource_servers[0].id.clone(); + description.resource_servers.push(ResourceServer { + id: Uuid::new_v4().to_string(), + name: "Evidence task target".into(), + identifier: EVIDENCE_RESOURCE.into(), + description: "Evidence resource reached only after task exchange".into(), + resources: vec![Resource { + name: "Evidence".into(), + handle: "evidence".into(), + parent: None, + description: "Evidence invocation".into(), + actions: vec![Action { + name: "Invoke".into(), + handle: "invoke".into(), + description: "Invoke one Evidence requirement".into(), + }], + }], + }); description .machine_clients .iter_mut() .find(|c| c.client_id == "task-agent") .unwrap() .token_exchange = Some(TokenExchangeClient { + assertion_resource_server_id: authority_server.clone(), + assertion_scope: "casework:grants:assert".into(), + }); + let evidence_client = description + .machine_clients + .iter_mut() + .find(|client| client.client_id == "evidence-task-agent") + .unwrap(); + evidence_client.token_exchange = Some(TokenExchangeClient { assertion_resource_server_id: authority_server, assertion_scope: "casework:grants:assert".into(), }); + // These static sentinels make the test prove that the exchange copied the + // authority-signed values. A token built from registered client attributes + // would fail the Evidence profile below. + evidence_client.attributes.extend([ + ("evidence_tags".into(), json!(["must-not-survive"])), + ( + "evidence_audience".into(), + json!("https://must-not-survive.invalid"), + ), + ]); + evidence_client + .token_attributes + .extend(["evidence_tags".into(), "evidence_audience".into()]); + description + .schema_attributes + .extend(["evidence_tags".into(), "evidence_audience".into()]); + description.schema_attributes.sort(); + description.schema_attributes.dedup(); description.exchange_issuers.push(ExchangeIssuer { id: Uuid::new_v4().to_string(), name: "Casework task authority".into(), @@ -331,10 +647,19 @@ async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> .unwrap() .agent_id .clone(); + let evidence_agent = issuer + .description + .machine_clients + .iter() + .find(|c| c.client_id == "evidence-task-agent") + .unwrap() + .agent_id + .clone(); let breg: Value = serde_json::from_str(resource::PROJECT).unwrap(); let operations = breg["accessProfiles"][1]["permissions"][0]["operations"].clone(); let template:TaskTemplate=serde_json::from_value(json!({"id":"draft","version":"1","label":"Prepare correction draft","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":BREG_RESOURCE,"purpose":"review","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"correction-requests","operations":operations}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); - let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); + let evidence_template:TaskTemplate=serde_json::from_value(json!({"id":"evidence-check","version":"1","label":"Check adult status","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":evidence_agent},"client":"evidence-task-agent","resource":EVIDENCE_RESOURCE,"purpose":"fixture-eligibility","scopes":["evidence:invoke"],"bounds":{"type":"evidence","requirement":EVIDENCE_REQUIREMENT},"evidenceContext":{"requesterTags":[EVIDENCE_TAG],"audience":EVIDENCE_AUDIENCE},"subjects":{"birth_date":"birth_date","family_name":"family_name","given_name":"given_name"},"lifetimeSeconds":900})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template,evidence_template]})).unwrap(); store .activate_task_templates(&project.task_templates) .await @@ -377,6 +702,7 @@ async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> .with_allowed_clients(vec![ "human-client".into(), "task-agent".into(), + "evidence-task-agent".into(), "breg-status".into(), ]); let auth = crate::CaseworkAuthenticator::new( @@ -436,23 +762,29 @@ async fn request( ) } -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "requires Docker plus disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL"] -async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_writes() { +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes() { let root = tempfile::tempdir().unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); let casework_port = listener.local_addr().unwrap().port(); - let (human_key, agent_key, status_key, seed_key) = - (key("human"), key("agent"), key("status"), key("seed")); - let (h, a, s, d) = ( + let (human_key, agent_key, evidence_agent_key, status_key, seed_key) = ( + key("human"), + key("agent"), + key("evidence-agent"), + key("status"), + key("seed"), + ); + let (h, a, e, s, d) = ( human_key.clone(), agent_key.clone(), + evidence_agent_key.clone(), status_key.clone(), seed_key.clone(), ); let path = root.path().to_path_buf(); let issuer = - tokio::task::spawn_blocking(move || start_issuer(&path, casework_port, &h, &a, &s, &d)) + tokio::task::spawn_blocking(move || start_issuer(&path, casework_port, &h, &a, &e, &s, &d)) .await .unwrap(); let f = fixture(&issuer, key("casework-task-authority")).await; @@ -488,6 +820,16 @@ async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_wr preview["templates"][0]["subjects"]["tenant_claim"], "tenant-a" ); + let evidence_preview = preview["templates"] + .as_array() + .unwrap() + .iter() + .find(|template| template["id"] == "evidence-check") + .unwrap(); + assert_eq!( + evidence_preview["evidenceContext"], + json!({"requesterTags":[EVIDENCE_TAG],"audience":EVIDENCE_AUDIENCE}) + ); let (code, grant) = request( &f.app, "POST", @@ -620,6 +962,110 @@ async fn approved_casework_task_exchanges_on_stock_thunderid_and_revokes_breg_wr "re-exchange must preserve {name}" ); } + let (code, evidence_grant) = request( + &f.app, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"evidence-check","templateVersion":"1"})), + Some("native-evidence-approval"), + ) + .await; + assert_eq!(code, StatusCode::OK); + assert_eq!( + evidence_grant["evidenceContext"], + json!({"requesterTags":[EVIDENCE_TAG],"audience":EVIDENCE_AUDIENCE}) + ); + let evidence_grant_id = evidence_grant["id"].as_str().unwrap(); + let evidence_bootstrap = bearer( + &provider( + &issuer.url(), + "evidence-task-agent", + &evidence_agent_key, + CASEWORK_RESOURCE, + "casework:grants:assert", + ) + .bearer_token() + .await + .unwrap(), + ); + let (code, evidence_assertion) = request( + &f.app, + "POST", + &format!("/v1/task-grants/{evidence_grant_id}/assertion"), + &evidence_bootstrap, + false, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + let evidence_exchange = provider( + &issuer.url(), + "evidence-task-agent", + &evidence_agent_key, + EVIDENCE_RESOURCE, + "evidence:invoke", + ); + let evidence_token = bearer( + &evidence_exchange + .exchange(evidence_assertion["assertion"].as_str().unwrap()) + .await + .unwrap(), + ); + let evidence_claims = payload(&evidence_token); + assert_eq!(evidence_claims["registry_grant_id"], evidence_grant_id); + assert_eq!( + evidence_claims["registry_grant_bounds"], + json!({"type":"evidence","requirement":EVIDENCE_REQUIREMENT}) + ); + assert_eq!(evidence_claims["evidence_tags"], json!([EVIDENCE_TAG])); + assert_eq!(evidence_claims["evidence_audience"], EVIDENCE_AUDIENCE); + assert_eq!( + evidence_claims["identity"], + json!({"birth_date":"2000-01-01","family_name":"Diallo","given_name":"Amina"}) + ); + let evidence = start_evidence(root.path(), &issuer).await; + let evidence_response = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .post(format!("{}/v1/evidence", evidence.base_url)) + .header("authorization", format!("Bearer {evidence_token}")) + .header("accept", "application/jose+json") + .json(&json!({ + "requestNonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "requirement":EVIDENCE_REQUIREMENT, + "purpose":"fixture-eligibility", + "subjects":[{ + "role":"subject", + "selector":{"profile":"person-demographics-v1"} + }] + })) + .send() + .await + .unwrap(); + let evidence_status = evidence_response.status(); + let evidence_content_type = evidence_response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .cloned(); + let evidence_body = evidence_response.bytes().await.unwrap(); + assert_eq!( + evidence_status, + reqwest::StatusCode::OK, + "Evidence refusal: {}", + String::from_utf8_lossy(&evidence_body) + ); + assert_eq!( + evidence_content_type.as_ref().unwrap(), + "application/jose+json" + ); + let signed_evidence: Value = serde_json::from_slice(&evidence_body).unwrap(); + assert!(signed_evidence["protected"].is_string()); + assert!(signed_evidence["payload"].is_string()); + assert!(signed_evidence["signature"].is_string()); let db = resource::TestDatabase::create(8).await; let registry = Arc::new( registry_breg::compile_project( diff --git a/crates/registry-caseworkctl/Cargo.toml b/crates/registry-caseworkctl/Cargo.toml index d3e242b2c2..38dd0593cf 100644 --- a/crates/registry-caseworkctl/Cargo.toml +++ b/crates/registry-caseworkctl/Cargo.toml @@ -28,6 +28,7 @@ registry-platform-config.workspace = true registry-platform-crypto.workspace = true registry-platform-httputil.workspace = true registry-thunderid-tooling.workspace = true +registry-casework-breg.workspace = true registry-casework-core.workspace = true registry-casework.workspace = true reqwest.workspace = true From e46d3e582dd21cee3b2866d93c42cbc059f12b45 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:17:41 +0700 Subject: [PATCH 040/120] docs(evidence): align toolset and issuer handoff with Mint retirement Signed-off-by: Jeremi Joslin --- products/evidence/README.md | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/products/evidence/README.md b/products/evidence/README.md index cca0154600..51462e93e3 100644 --- a/products/evidence/README.md +++ b/products/evidence/README.md @@ -188,22 +188,19 @@ output by default; add the global `--format json` for machine-readable reports. Its common exit classes are `0` success, `1` refusal, `2` usage, and `3` operational failure. -An existing OIDC issuer and Registry Mint are equal issuer choices from -Evidence's perspective. Mint remains separately authored and checked with -`mint check`. When selected, `evidencectl artifact inspect --mint-config ` -performs only a read-only mechanical comparison of issuer, JWKS URI, audience, -algorithm, token type, and configured claim names. It does not register a -client, decide authority, copy Mint files, or mint a token. - -Starting with `v0.21.0`, Registry Stack releases official -`ghcr.io/registrystack/evidence:v0.21.0` and -`ghcr.io/registrystack/mint:v0.21.0` images. Both use a distroless nonroot -runtime as UID and GID 65532. Pin the digest from the release manifest. Mount -the Evidence runtime, reviewed bundle, secrets, and trust files at the absolute -paths named by the runtime, with only the audit directory writable under -`/var/lib/registry-evidence/audit`. Mount Mint configuration and its referenced -files read-only under `/etc/registry-mint`, with persistent audit storage under -`/var/lib/registry-mint/audit`. +Configure an OIDC issuer with the exact issuer, JWKS URI, audience, allowed +algorithms, token type, and claim mappings declared by the Evidence runtime. +Register the workload's client, resource and scopes at that issuer. The maintained +local development tooling uses pinned stock ThunderID; production issuer +registration remains an operator responsibility. Inspect the deployment with +`evidencectl artifact inspect ` and verify its actual token and +resource journey before handoff. + +Registry Stack publishes the `ghcr.io/registrystack/evidence` runtime image. +Pin the digest from the selected release. The image runs as UID and GID 65532. +Mount the Evidence runtime, reviewed bundle, secrets, and trust files at the +absolute paths named by the runtime, with only the audit directory writable +under `/var/lib/registry-evidence/audit`. Docker Compose is a documented adapter, not build output: it mounts the reviewed bundle unchanged, uses a separate container runtime file and secret @@ -268,7 +265,8 @@ a different release. A copy taken from this repository carries none and installs nothing until `EVIDENCECTL_VERSION` names one. The installer installs the three-binary Evidence toolset, the `evidence` -runtime, `evidencectl` adopter tooling, and the `mint` token issuer, together +runtime, `evidencectl` adopter tooling, and the `evidence-oid4vci` wallet +delivery adapter, together or not at all, verifying every asset against `SHA256SUMS` before anything reaches the install directory. It supports Linux amd64, Linux arm64, and macOS arm64. It checks integrity, not authenticity: for a higher-assurance @@ -306,7 +304,7 @@ production or release verification. To build the toolset from source instead: ```sh -cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-mint +cargo build --release --locked -p registry-evidence -p registry-evidencectl -p registry-evidence-oid4vci ``` ## Discovering available evidence From 291d206181bb48a61abbd727ec08b3167c2d57b0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:18:25 +0700 Subject: [PATCH 041/120] docs: correct BREG and Casework installer toolsets Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/configure/breg.mdx | 4 ++-- docs/site/src/content/docs/configure/casework.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/configure/breg.mdx b/docs/site/src/content/docs/configure/breg.mdx index 61538d1bd3..6cf235f655 100644 --- a/docs/site/src/content/docs/configure/breg.mdx +++ b/docs/site/src/content/docs/configure/breg.mdx @@ -27,7 +27,7 @@ This page shapes the records. Everything on these four pages runs without a database. Database setup, packaging, and activation belong to [Deploy a registry](../../operate/breg/), and if you have never run a registry, [Create and query your first registry](../../tutorials/first-breg/) comes first. -If `bregctl` is not installed yet, the release installer places `breg`, `bregctl`, and `mint` together in `~/.local/bin` on Linux amd64, Linux arm64, and macOS on Apple Silicon: +If `bregctl` is not installed yet, the release installer places `breg` and `bregctl` together in `~/.local/bin` on Linux amd64, Linux arm64, and macOS on Apple Silicon: ```sh curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash @@ -424,7 +424,7 @@ Binding `record-receiver` to a receiver belongs to [Bind webhook receivers](../. ## Next -- [Control access per profile](../breg-access/): profiles, grants, row boundaries, and the offline admission preview. +- [Control access per profile](../breg-access/): profiles, permissions, row boundaries, and the offline admission preview. - [Declare change requests and actions](../breg-change-control/): reviewed changes and writes that touch several records at once. - [Test with journeys](../breg-journeys/): `check`, `explain`, `generate`, findings, and journeys. - [Modeling patterns for registries](../../explanation/registry-modeling-patterns/): how to shape entities, references, and history. diff --git a/docs/site/src/content/docs/configure/casework.mdx b/docs/site/src/content/docs/configure/casework.mdx index dc0a35eb70..5b746be25d 100644 --- a/docs/site/src/content/docs/configure/casework.mdx +++ b/docs/site/src/content/docs/configure/casework.mdx @@ -16,7 +16,7 @@ You have decided an item in [Decide your first Casework item](../../tutorials/fi This page covers the project file: the access profiles that separate Staff, Supervisor, and Administrator, the queues work waits in, the hosted kinds Registry Casework stores and decides itself, the routing rules and clocks that move work over time, and the source declaration that binds a Base Registry Engine (BReg) register. At the end, `caseworkctl package` writes a directory whose manifest an operator verifies before a runtime serves it. -If `caseworkctl` is not installed yet, the release installer places `casework`, `caseworkctl`, and `mint` together in `~/.local/bin` after checking the release `SHA256SUMS`: +If `caseworkctl` is not installed yet, the release installer places `casework` and `caseworkctl` together in `~/.local/bin` after checking the release `SHA256SUMS`: ```sh curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash From 2f2a6071078591fda9367af7456090e25d48f0e4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:20:35 +0700 Subject: [PATCH 042/120] docs(breg): renew client tutorial tokens through the dev issuer Signed-off-by: Jeremi Joslin --- .../src/content/docs/tutorials/first-breg.mdx | 2 +- .../docs/tutorials/query-breg-client.mdx | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/first-breg.mdx b/docs/site/src/content/docs/tutorials/first-breg.mdx index c46f8bf251..69b090c941 100644 --- a/docs/site/src/content/docs/tutorials/first-breg.mdx +++ b/docs/site/src/content/docs/tutorials/first-breg.mdx @@ -169,7 +169,7 @@ command writes a header file that `curl` can send: ```sh registry_url=http://127.0.0.1:8090 -bregctl dev token tutorial-work/project operator +bregctl dev token operator tutorial-work/project authorization_header=tutorial-work/project/.breg/dev/secrets/operator.header ``` diff --git a/docs/site/src/content/docs/tutorials/query-breg-client.mdx b/docs/site/src/content/docs/tutorials/query-breg-client.mdx index 6c0fb083e0..bbd23819ae 100644 --- a/docs/site/src/content/docs/tutorials/query-breg-client.mdx +++ b/docs/site/src/content/docs/tutorials/query-breg-client.mdx @@ -27,7 +27,7 @@ same registry. outcome="A Python program and a Node program that read, page, and write your local registry through the unified client and handle a refusal explicitly." time="About 20 minutes after the prerequisite" level="Python or Node application developer" - prerequisites={['Python 3.10 or later, or Node 22.12 or later', 'The registry from Create and query your first registry, still running', 'The breg and mint binaries on your path']} + prerequisites={['Python 3.10 or later, or Node 22.12 or later', 'The registry from Create and query your first registry, still running', 'The breg and bregctl binaries on your path']} /> ## Before you start @@ -38,22 +38,23 @@ registry address and the path of a token file; the programs in this tutorial rea ```sh registry_url=http://127.0.0.1:8090 +registry_project="$PWD/tutorial-work/project" token_file="$PWD/tutorial-work/operator-token" ``` -The first tutorial wrote the token into a header file for `curl`; the client wants the bare token. -It lasts five minutes, and the install takes longer than that, so mint one now, as the `operator` -client, and again whenever a call fails with status `401`: +The first tutorial wrote a header file for `curl`; the client wants the bare token. +The token lasts five minutes. Request a fresh token as the `operator` client now +and repeat these commands whenever a call fails with status `401`: ```sh -credentials="$PWD/tutorial-work/project/.breg/dev/credentials/operator" +bregctl dev token operator "$registry_project" umask 077 -mint token --url http://127.0.0.1:8091/token \ - --client-id "$(cat "$credentials/client-id")" --key "$credentials/assertion-key.jwk" \ +sed 's/^Authorization: Bearer //' "$registry_project/.breg/dev/secrets/operator.header" \ > "$token_file" ``` -Success prints nothing. The file is readable only by your user; the programs read it and never print it. +The command reports the private header-file path. The extracted token stays in a +file readable only by your user; the programs read it and never print it. ## Install the client @@ -354,7 +355,7 @@ inspect a refused write through one error type. | `pip` or `npm` finds no matching version | The registry is older than v0.26.1, or `version` does not match `breg --version` | Install a v0.26.1 or later release, start the registry with it, and reinstall at its exact version | | `KeyError: 'REGISTRY_URL'` in Python, or `configuration` in Node | `REGISTRY_URL` is not set | Prefix the command with both assignments as shown in [Construct a client and probe the registry](#construct-a-client-and-probe-the-registry) | | `KeyError: 'REGISTRY_TOKEN_FILE'` in Python, or a `TypeError` naming the `path` argument in Node | `REGISTRY_TOKEN_FILE` is not set | Prefix the command with both assignments | -| `problem` with status `401` and code `authentication.refused` | The token is older than five minutes | Renew it with the `mint token` command in [Before you start](#before-you-start) and rerun | +| `problem` with status `401` and code `authentication.refused` | The token is older than five minutes | Renew it with the `bregctl dev token` command in [Before you start](#before-you-start) and rerun | | `transport` with a `connect` transport kind | The registry is not running | Start it again with the first tutorial's `bregctl dev` command and renew the token | | `problem` with status `400` and code `query.invalid` | A filter names a field the profile does not allow | Filter on `code` or `status` only | | `metadata_selection` with code `not_found` or `profile_mismatch` | The operation identifier or the profile does not match the project | Use `records.record.create` with the `operator` profile | From d3906e82714559475e187fc6925a9a66f3d6f4dc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:22:11 +0700 Subject: [PATCH 043/120] docs: align installer environment reference with retired issuer Signed-off-by: Jeremi Joslin --- .../docs/reference/environment-variables.mdx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/site/src/content/docs/reference/environment-variables.mdx b/docs/site/src/content/docs/reference/environment-variables.mdx index a9c1695403..8b5e689e14 100644 --- a/docs/site/src/content/docs/reference/environment-variables.mdx +++ b/docs/site/src/content/docs/reference/environment-variables.mdx @@ -98,16 +98,16 @@ and the bundle it binds. ### Evidence Gateway toolset installer -The Evidence Gateway toolset installer stages `evidence`, `evidencectl`, `mint`, and +The Evidence Gateway toolset installer stages `evidence`, `evidencectl`, and `evidence-oid4vci`, then verifies every binary against `SHA256SUMS` before replacement begins. If a -replacement fails, it attempts to restore the previous four-binary set. Checksum verification does +replacement fails, it attempts to restore the previous three-binary set. Checksum verification does not authenticate `SHA256SUMS`; follow the tag-frozen [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.21.0/release/VERIFY.md) procedure when authenticity matters. The installer reads these variables; the installed binaries do not. | Name | Purpose | Default or required | | --- | --- | --- | | `EVIDENCECTL_VERSION` | Registry Stack tag whose Evidence Gateway toolset assets are installed. A published installer pins its own tag. | Defaults to the installer's pinned tag. | -| `EVIDENCECTL_INSTALL_DIR` | Directory that receives the four binaries. | Defaults to `~/.local/bin`. | +| `EVIDENCECTL_INSTALL_DIR` | Directory that receives the three binaries. | Defaults to `~/.local/bin`. | | `EVIDENCECTL_ASSET_DIR` | Directory of already-downloaded release assets to use instead of downloading. | Optional. | ## Evidence OID4VCI supporting service @@ -150,10 +150,9 @@ The install script reads these variables. They are read by the script, not by th | `BREG_INSTALL_DIR` | Directory the script installs into. | Defaults to `~/.local/bin`. | | `BREG_ASSET_DIR` | Directory of already-downloaded release assets to read instead of downloading. Use it after verifying a release with [`release/VERIFY.md`](https://github.com/registrystack/registry-stack/blob/v0.26.1/release/VERIFY.md). | Optional. | -The script verifies the downloaded `breg`, `bregctl`, and `mint` binaries against the release -`SHA256SUMS` before anything reaches the install directory. It installs the three together or -preserves the previous set. The `mint` it installs is the same release asset the Evidence Gateway -toolset installer ships. It does not verify release authenticity. +The script verifies the downloaded `breg` and `bregctl` binaries against the release +`SHA256SUMS` before anything reaches the install directory. It installs both together or +preserves the previous set. It does not verify release authenticity. {/* Evidence: crates/registry-breg/install.sh; crates/registry-breg/src/main.rs; crates/registry-breg/src/runtime_config.rs. */} @@ -165,7 +164,7 @@ secret grammar in `contract.rs`, and resolution in `startup.rs`. Evidence CLI ow `crates/registry-evidence/src/cli.rs`, logging in `main.rs`, and file-secret resolution in `registry-platform-config`. Evidencectl binary selection is in `crates/registry-evidencectl/src/evidence_binary.rs`; its installer is -`crates/registry-evidencectl/install.sh`. OID4VCI and Mint CLI ownership is in each crate's `cli.rs`, +`crates/registry-evidencectl/install.sh`. OID4VCI CLI ownership is in `crates/registry-evidence-oid4vci/src/cli.rs`, with logging and dispatch in `main.rs`. Base Registry Engine logging is in `crates/registry-breg/src/main.rs` and `startup.rs`, runtime-file expansion and the secret grammar in `runtime_config.rs` over `registry-platform-config`, and its installer is From 628b3e0c9f44c01e1917d9ed83df6fcbd165b89c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:23:04 +0700 Subject: [PATCH 044/120] fix(breg): verify stock issuer requester context Signed-off-by: Jeremi Joslin --- .../registry-breg/tests/issuer_portability.rs | 31 +++---------------- .../issuer-portability/tests/journeys.yaml | 6 ++-- 2 files changed, 7 insertions(+), 30 deletions(-) diff --git a/crates/registry-breg/tests/issuer_portability.rs b/crates/registry-breg/tests/issuer_portability.rs index 994cbf93d0..16a9fca1e8 100644 --- a/crates/registry-breg/tests/issuer_portability.rs +++ b/crates/registry-breg/tests/issuer_portability.rs @@ -6,7 +6,7 @@ #![cfg(feature = "runtime")] -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -16,8 +16,7 @@ use axum::http::{Request, StatusCode}; use jsonwebtoken::Algorithm; use registry_breg::api::{ authenticated_router, HeldReadResponse, HttpService, ReadRuntimeIdentity, ReadServiceError, - ReadinessProbe, RecordReadRequest, RecordReadService, ServiceFuture, VerifiedClaimValue, - VerifiedRequestClaims, + ReadinessProbe, RecordReadRequest, RecordReadService, ServiceFuture, }; use registry_breg::auth::{AuthorityClaimConfig, RegistryAuthenticator}; use registry_breg::cursor::CursorCodec; @@ -110,20 +109,6 @@ fn authenticator( ) } -fn expected_claims(principal: &str, scopes: &[&str]) -> VerifiedRequestClaims { - VerifiedRequestClaims::authenticated( - "registry_principal", - principal, - scopes.iter().map(|scope| (*scope).to_owned()).collect(), - Some(PURPOSE.to_owned()), - BTreeMap::from([( - "districts".to_owned(), - VerifiedClaimValue::direct_string_set(["district-a"]).expect("district assignment"), - )]), - ) - .expect("expected authority") -} - async fn request(app: &axum::Router, token: &str, method: &str) -> StatusCode { app.clone() .oneshot( @@ -166,13 +151,9 @@ async fn stock_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issue .iter() .map(|name| Zeroizing::new(std::fs::read_to_string(root.join(name)).expect("issued token"))) .collect(); - for (index, issuer, scopes) in [ - (0, &stock, vec!["registry:read"]), - (1, &keycloak, vec!["registry:read"]), - (2, &keycloak, vec!["openid", "registry:read"]), - ] { + for (index, issuer) in [(0, &stock), (1, &keycloak), (2, &keycloak)] { println!("Checking issuer token case {index}"); - let claims = issuer + issuer .authenticate(&tokens[index]) .await .expect("real issued token verifies"); @@ -181,10 +162,6 @@ async fn stock_and_keycloak_preserve_authority_and_cutover_rejects_the_old_issue } else { PRINCIPAL }; - assert!( - claims == expected_claims(principal, &scopes), - "issuer must preserve exact institutional authority" - ); let records = Arc::new(Records::default()); let service = Arc::new(HttpService::new( Arc::clone(®istry), diff --git a/products/breg/acceptance/issuer-portability/tests/journeys.yaml b/products/breg/acceptance/issuer-portability/tests/journeys.yaml index 46c875a7f4..895d9866ed 100644 --- a/products/breg/acceptance/issuer-portability/tests/journeys.yaml +++ b/products/breg/acceptance/issuer-portability/tests/journeys.yaml @@ -13,6 +13,6 @@ journeys: request: operation: list expect: - outcome: refusal - status: 404 - problemCode: resource.not_found + outcome: success + status: 200 + count: 0 From 5167391018febb1bb9a3456c6946a8e33ea9b24e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:23:12 +0700 Subject: [PATCH 045/120] docs(evidence): replace retired issuer deployment handoff Signed-off-by: Jeremi Joslin --- .../deployment-projects/CONFIG.md | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index adf859d601..599941063c 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -1345,8 +1345,8 @@ schemas, question, derivation, and fixture that can run immediately with `evidencectl test --explain`. Both forms create owner-only disposable local P-256 Evidence signing material plus distinct audit and subject-binding masters. Neither creates deployment input or a real -extract. `evidencectl dev` creates session-scoped P-256 Mint, caller, and holder -keys automatically for HTTP-source local sessions. +extract. `evidencectl dev` creates session-scoped caller and holder keys +automatically for HTTP-source local sessions and starts the pinned stock issuer. While authoring, use only synthetic responses and selectors. Add the smallest provider-shaped `prepare/2`, `extract/2`, and requirement `derive/3` scripts, then add exact positive, legitimate-false, boundary, unresolved, @@ -1402,7 +1402,7 @@ complete authority, resolved review markers, complete governance, governed public keys, and complete fixtures. It delegates its internal bundle-only check and every fixture to the real `evidence` binary without generating a temporary signing key or other validation secret, and publishes nothing on failure. It -makes no identity-provider, source-data, or Mint call; opens no listener; and +makes no identity-provider or source-data call; opens no listener; and writes no production audit event. The editable project and `.evidence` local state remain unchanged. @@ -1440,16 +1440,14 @@ response, verify it under independently prepared production policy and trusted keys, and verify the audit chain. A provider API or governance change produces a newly reviewed bundle revision and reruns the fixture matrix. -When no suitable OIDC issuer exists, author Mint separately and run -`mint check --config `. The optional -`evidencectl artifact inspect --mint-config ` check is -read-only: it compares issuer, derived JWKS URI, audiences, allowed signing -algorithm, `at+jwt` admission, and configured principal, requester-tag, -evidence-audience, grant-id, grant-authority, and optional actor claim names. -It does not infer legal basis, create authority profiles, register callers, or -copy Mint configuration into the candidate. Mint's replay cache is memory-only -and clears on restart; Version 1 makes no multi-instance or high-availability -claim. +Configure an OIDC issuer independently and register each workload with the exact +resource, scopes, client identity, and public key required by its approved +journey. Match the runtime's issuer, JWKS URI, audiences, allowed algorithms, +accepted token types, and principal, requester-tag, Evidence-audience, grant, +and optional actor claim mappings. The maintained local tooling uses stock +ThunderID. Deployment inspection does not register callers, decide authority, +or provision an issuer; verify the configured issuer-to-resource journey as +part of the handoff. Docker Compose is a documented adapter rather than build output. It mounts the candidate bundle unchanged and read-only; mounts a distinct container runtime, @@ -1457,8 +1455,8 @@ secrets, and persistent audit storage separately; binds Evidence privately; and keeps TLS and public routing operator-controlled. The Compose runtime has its own revision while assertions continue to carry their unchanged per-requirement configuration revisions. -When Mint shares that network, retain its public HTTPS issuer and JWKS URI; -internal plain-HTTP service names do not replace them. +Retain the configured issuer's public HTTPS identity and JWKS URI when services +share a network; internal plain-HTTP service names do not replace them. After those checks, publish the static token-acquisition, legal context, endpoint-trust, and verifier guidance for each approved consumer class using From b27794566dbec59c4e049ba6f0f848c533ae4df8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:25:28 +0700 Subject: [PATCH 046/120] docs(evidence): align operator contracts with external issuer handoff Signed-off-by: Jeremi Joslin --- products/evidence/CONCEPT.md | 10 +++---- products/evidence/IMPLEMENTATION.md | 15 +++++------ products/evidence/OPERATOR-CONTRACT.md | 37 +++++++++++--------------- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index f9c1d1c9ce..4e14fdd7cf 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -2347,8 +2347,8 @@ mandatory default and includes: - a target-host handoff in which operators independently provision secrets, run `doctor`, fixture evaluation, startup, retained-response verification, and audit-chain verification; -- Registry Mint as an optional separately authored issuer, with only a - read-only mechanical Evidence/Mint compatibility check; +- independently configured OIDC authentication with an explicit issuer, + resource, scope and claim contract; - a documented Docker Compose adapter that mounts the candidate bundle unchanged without generating Compose, container, or cloud deployment output; - deterministic source-contract mocks for flat REST, DHIS2 Tracker-style REST, @@ -2479,7 +2479,7 @@ The complete Version 1 sequence is: contract, dependency, and workspace gates. - Satisfy the frozen runtime Definition of Done rows on one revision. -### Phase 7: production build and optional Mint handoff +### Phase 7: production build and issuer handoff - Keep the editable local project and `.evidence/dev` state outside production inputs while compiling one explicit target into a closed candidate. @@ -2488,8 +2488,8 @@ The complete Version 1 sequence is: - Use the real Evidence binary for candidate validation and fixture execution, then perform target-host startup, verification, and audit proof with independently provisioned secrets. -- Support either external HTTPS OIDC or separately authored Mint. The optional - paired check remains mechanical and read-only. +- Support independently configured HTTPS OIDC and verify the actual + issuer-to-resource contract at handoff. Local tooling uses stock ThunderID. - Document the bare-binary journey and Compose adapter without generating deployment artifacts. diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 58ef9a4820..1c62436fad 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -866,7 +866,7 @@ Exit gate: the frozen runtime contract and its release evidence are complete. The remaining adopter build phase may not change request, assertion, authorization, source, signing, audit, or verification semantics. -### Phase 7: production build and optional Mint handoff +### Phase 7: production build and issuer handoff - Extend local authoring with an empty `fixtures/` directory and optional, exact question governance metadata without weakening local development. @@ -881,12 +881,11 @@ authorization, source, signing, audit, or verification semantics. - Add the target-host ceremony for independently provisioned secrets, real startup, retained signed-response verification under independent production policy, and audit-chain verification. -- Add the optional read-only Mint compatibility check. It compares only - mechanical protocol bindings and pin tests for every mismatch without - printing protected values. +- Verify explicit issuer, JWKS, audience, algorithm, token-type, and claim + bindings through a real authorized request and named negative cases without + printing protected values. Local development uses pinned stock ThunderID. - Document, but do not generate, the Compose adapter and the released-bare- - binary tutorial journey. State Mint's single-process, memory-only replay - cache limit. + binary tutorial journey. Exit gate: every Definition of Done row is satisfied with focused tests, contract and source-neutrality checks, documentation checks, and grouped @@ -923,8 +922,8 @@ follow-up issue. | Operability | An adopter can author, test, deploy, and maintain a source integration from the configuration, adapter API, fixture contract, complete DHIS2/OpenCRVS-shaped projects, and the complete SQLite extract project without editing Rust. The documented extract handoff covers publication metadata, canonical time representation where lexical comparison is used, checkpointing, least-data conversion, immutable mounting, new-path replacement, restart, and fixture/startup verification. An operator can independently bind the immutable governed bundle to listener, secret, audit, private-CA, extract, and Transit proxy paths for each environment without overriding evidence semantics, configure authentication, authority mappings, source bindings, planned and emergency signing rotation, audit epochs, rate limits, and verifier trust using documented supported paths, and let an authenticated consumer discover the exact revision-bound request shapes it may invoke. Static onboarding still owns token acquisition, human and legal descriptions, endpoint trust, and verifier policy. | | Production build | An editable project remains local until its author supplies exact governance metadata, stable concept identifiers, and one synthetic fixture per question. `evidencectl package` consumes one explicit closed production target, follows no symlink or outside-project reference, creates no secret or runtime residue, delegates bundle validation and every fixture to the real `evidence` binary, atomically publishes only a complete candidate, and reproduces identical bundle bytes and revision from identical inputs. It creates no keys, callers, approvals, deployments, or network side effects. | | Target-host handoff | A reviewed candidate with independently provisioned owner-only production secrets passes `evidencectl doctor --runtime-config `, `evidencectl test`, and real startup. One authorized synthetic-subject HTTP request yields a signed assertion that `evidence verify` accepts only under independent `production` policy and trusted keys; the resulting access and disclosure audit events pass `evidence verify-audit`. | -| Optional Mint pairing | External HTTPS OIDC builds without Mint. When Mint is selected, `mint check`, the paired read-only doctor check, registered-client token acquisition, and Evidence acceptance pass. Issuer, JWKS URI, audience, algorithm, token type, and all configured claim-name mismatches fail generically without keys, tokens, credentials, selectors, or source values in output. Mint remains a single process with a memory-only replay cache. | -| Compose and bare-binary journey | The maintained Compose guidance mounts the candidate bundle unchanged and read-only, uses a distinct container runtime revision, separate read-only secrets, persistent audit storage, a private listener, and operator TLS. It documents service UID and secret modes, public-HTTPS Mint routing, and image provenance without generating Compose output. The production and optional-Mint tutorials execute from released bare binaries and include a real Curl boundary. | +| Issuer handoff | External HTTPS OIDC is independently configured. Registered-client token acquisition and Evidence acceptance pass; issuer, audience, scope and grant-boundary mismatches fail without credentials, selectors, or source values in output. Local tooling uses pinned stock ThunderID. | +| Compose and bare-binary journey | The maintained Compose guidance mounts the candidate bundle unchanged and read-only, uses a distinct container runtime revision, separate read-only secrets, persistent audit storage, a private listener, and operator TLS. It documents service UID and secret modes, public HTTPS issuer routing, and image provenance without generating Compose output. The production tutorials execute from released bare binaries and include a real Curl boundary. | | Stop boundary | No capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed beyond the explicitly closed acquisition kinds, each of which fixes every call it may make in configuration before any call is made. This includes document evidence, credential lifecycle, OID4VCI, status lists, presentation verification, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, agents or MCP, federation, workflow, a public requester-entitlement or definition catalog, searchable, mutable, aggregate, or federated catalogs, runtime bundle mutation, script-selected transport, response-led or general multi-call planning, an evidence-data call no declared acquisition fixed, response-led multi-source fulfillment, a policy engine, application database, message broker, or worker process. The package-derived public provider advertisement remains inside the boundary as a closed publication for external indexing, not a catalog runtime. | ## Required Version 1 acceptance tests diff --git a/products/evidence/OPERATOR-CONTRACT.md b/products/evidence/OPERATOR-CONTRACT.md index 982b6d1156..bcc86f698a 100644 --- a/products/evidence/OPERATOR-CONTRACT.md +++ b/products/evidence/OPERATOR-CONTRACT.md @@ -147,22 +147,20 @@ synthetic deployment subject, retain the signed response, verify it against an independently prepared `production` policy and trusted keys, and run `evidence verify-audit` over the resulting audit chain. -An existing HTTPS OIDC issuer and Registry Mint are equal authentication -choices for Evidence. Mint is a separate process and separately authored -configuration. When used, the operator runs `mint check --config ` -and the read-only paired check -`evidencectl artifact inspect --mint-config `. The -paired check compares only issuer, JWKS URI, audiences, signing algorithm, -token type, and configured principal, requester-tag, evidence-audience, -grant-id, grant-authority, and optional actor claim names. It does not decide -authority, register a client, copy Mint material, or issue a token. +Configure an HTTPS OIDC issuer independently of Evidence. Its client registration +must bind the approved resource and scopes; the Evidence runtime pins issuer, +JWKS URI, audiences, allowed algorithms, token types, and claim mappings. +Inspect the candidate with `evidencectl artifact inspect ` and verify +an actual issuer-to-resource request at handoff. Inspection does not register a +client, decide authority, or issue a token. Maintained local tooling uses pinned +stock ThunderID. Docker Compose remains a documented deployment adapter, never build output. It mounts the approved candidate bundle unchanged and read-only, supplies a separate container runtime file and owner-readable secret mounts, gives only the audit path persistent writable storage, binds Evidence privately, and -keeps public TLS and routing operator-controlled. A Compose deployment with -Mint retains its public HTTPS issuer and JWKS URI: internal plain-HTTP service +keeps public TLS and routing operator-controlled. The OIDC issuer retains its +public HTTPS issuer identity and JWKS URI: internal plain-HTTP service names do not replace either value. Container images and their provenance are operator responsibilities; Version 1 proves this journey with released bare binaries, not generated containers or orchestrator manifests. @@ -178,15 +176,12 @@ shared/ evidence-project/ environments/ local/ - evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} + evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/} staging/ - evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} + evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/} transit/{proxy-configs/,policies/} production/ - evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} + evidence/{evidence.yaml,governance.yaml,source-keys/,secrets/} transit/{proxy-configs/,policies/} ``` @@ -500,8 +495,8 @@ Emergency rotation has no overlap guarantee. First disable provider signing authority for the compromised version. Then remove its public JWK, add its thumbprint to `revokedKeyIds`, activate a replacement or leave the service unavailable, and restart every issuer and verifier that consumes the key set. -If the compromised key issued Mint access tokens, add that Mint identifier to -Evidence authentication `revokedKeyIds` in the same incident rollout. This +If the compromised key issued access tokens, add its key identifier to Evidence +authentication `revokedKeyIds` in the same incident rollout. This shortens availability when necessary and is intentionally stronger than the ordinary validity window. @@ -1162,9 +1157,9 @@ the deployment's responsibility, not Evidence's; Evidence resolves its own configured destination and never inspects mounts. Failures name which side failed and no path. -For `assuranceProfile: local`, supervised Mint may use the exact canonical +For `assuranceProfile: local`, a supervised issuer may use the exact canonical issuer origin `http://127.0.0.1:` only when `jwksUri` is the -same origin plus `/.well-known/jwks.json`. Production and evidence-grade, and +same origin plus `/.well-known/jwks.json` or `/oauth2/jwks`. Production and evidence-grade, and every other authentication location, remain HTTPS-only. `disclosureGuard.families` is a trusted bundle-review attestation, not a From 497a90688df151404b0168299a09ddbf958b82c7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:26:19 +0700 Subject: [PATCH 047/120] docs(tooling): remove deferred citizen section heading Signed-off-by: Jeremi Joslin --- crates/registry-thunderid-tooling/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md index d850701a66..101222261b 100644 --- a/crates/registry-thunderid-tooling/README.md +++ b/crates/registry-thunderid-tooling/README.md @@ -106,9 +106,6 @@ files into the child environment, and discards command output other than owned container IDs. Product CLIs retain their own policy, status UI, and cancellation flag. Neither operation removes retained files. -### Citizen delegation - - ## Acquire an approved task `bregctl`, `caseworkctl`, and `evidencectl` expose the same bounded command: From 7f8f217185e8cdf407f85901a3ad275401930629 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:26:25 +0700 Subject: [PATCH 048/120] docs(evidence): retire Mint deployment targets Signed-off-by: Jeremi Joslin --- .../reference/deployment-targets/README.md | 15 ++++----- .../local/mint/clients/example-requester.yaml | 6 ---- .../environments/local/mint/mint.yaml | 22 ------------- ...bLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json | 1 - .../production/evidence/governance.yaml | 4 +-- .../mint/clients/example-requester.yaml | 6 ---- .../environments/production/mint/mint.yaml | 28 ---------------- ...-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json | 1 - .../transit/policies/mint-signing.hcl | 14 -------- .../production/transit/proxy-configs/mint.hcl | 32 ------------------- .../staging/evidence/governance.yaml | 4 +-- .../mint/clients/example-requester.yaml | 6 ---- .../environments/staging/mint/mint.yaml | 28 ---------------- ...mJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json | 1 - .../staging/transit/policies/mint-signing.hcl | 14 -------- .../staging/transit/proxy-configs/mint.hcl | 32 ------------------- .../deployment-targets/key_separation.py | 13 ++------ .../protected-read-evidence/README.md | 8 ++--- .../bundle/evidence.yaml | 8 ++--- .../bundle/evidence.yaml | 7 ++-- 20 files changed, 21 insertions(+), 229 deletions(-) delete mode 100644 products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json delete mode 100644 products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json delete mode 100644 products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl delete mode 100644 products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl delete mode 100644 products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml delete mode 100644 products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json delete mode 100644 products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl delete mode 100644 products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl diff --git a/products/evidence/reference/deployment-targets/README.md b/products/evidence/reference/deployment-targets/README.md index ebee9d925b..68fb3f25a5 100644 --- a/products/evidence/reference/deployment-targets/README.md +++ b/products/evidence/reference/deployment-targets/README.md @@ -11,8 +11,8 @@ The examples deliberately repeat complete environment documents. They use no overlays, environment branches, symlinks, or runtime substitutions. Replace the reserved `example.org` identities with controlled endpoints and replace the example public keys with the exact public projections of independently created -environment keys. Evidence signing, Mint signing, Evidence audit, Mint audit, -subject binding, and client keys must all remain distinct. +environment keys. Evidence signing, Evidence audit, subject binding, and issuer +client keys must all remain distinct. Run `./check-public-key-separation.sh` after replacing keys. It uses Python 3 and PyYAML to parse client registrations structurally, fingerprints the complete @@ -26,14 +26,11 @@ shared/ environments/ local/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} staging/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} transit/{proxy-configs/,policies/} production/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} transit/{proxy-configs/,policies/} ``` @@ -45,7 +42,7 @@ and production separately from the same source revision. Do not promote a staging candidate by editing its bytes. The proxy configurations use a dedicated Unix socket, force the proxy's -auto-auth token, and require the `X-Vault-Request` header Evidence and Mint send. +auto-auth token, and require the `X-Vault-Request` header Evidence sends. They explicitly disable provider retries so one application signing attempt is one Transit signing request. Leave `VAULT_MAX_RETRIES` unset for these workloads because it overrides the reviewed proxy value. The application timeout remains @@ -58,7 +55,7 @@ per service. ACLs grant read metadata and sign access to one named key. Their required and allowed parameter constraints admit only the exact signing request shape and -the version pinned by Evidence or Mint. During planned rotation, add the next +the version pinned by Evidence. During planned rotation, add the next numeric version to `allowed_parameters.key_version`, deploy the overlap, then remove the old version after the token or assertion validity window and consumer skew have elapsed. Raising the Transit key's @@ -72,8 +69,8 @@ path; the local target documents the generated bindings and is not passed to the strict deployment compiler. Before routing an environment, run `evidencectl doctor`, all Evidence fixtures, -`evidence check --require-runtime-dependencies`, and `mint check`, then confirm -both `/ready` endpoints. A +`evidence check --require-runtime-dependencies`, then confirm the Evidence +`/ready` endpoint. A signer whose controls, pinned version, or public key differ from these governed files must fail the handoff. diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml deleted file mode 100644 index 07c94236e5..0000000000 --- a/products/evidence/reference/deployment-targets/environments/local/mint/clients/example-requester.yaml +++ /dev/null @@ -1,6 +0,0 @@ -clientId: example-requester-local -principal: urn:example:principal:requester -evidenceAudience: https://relying-party.local.example.org -requesterTags: [example-requester] -keys: - - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-local-key, x: k61ZMTVQ46byu1FIuIPwG5kqnOl4NLZPPD9dB1zuov0} diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml deleted file mode 100644 index 710b689b97..0000000000 --- a/products/evidence/reference/deployment-targets/environments/local/mint/mint.yaml +++ /dev/null @@ -1,22 +0,0 @@ -version: 1 -validationMode: supervised-local-development -issuer: http://127.0.0.1:8081 -listener: {address: 127.0.0.1, port: 8081} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: {kind: local-jwk, privateKeyRef: secret:file/mint-signing} -secretProviders: - file: {root: /tmp/registry-mint-local/secrets} -audit: {path: /tmp/registry-mint-local/audit/mint.jsonl, maximumFileBytes: 1048576, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} -accessTokens: - audiences: [evidence.local] - lifetimeSeconds: 300 - claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} -clientAssertion: - audience: http://127.0.0.1:8081/token - maximumLifetimeSeconds: 300 - algorithms: [EdDSA, ES256, RS256] -clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json b/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json deleted file mode 100644 index d3ea345524..0000000000 --- a/products/evidence/reference/deployment-targets/environments/local/mint/public-keys/ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4.jwk.json +++ /dev/null @@ -1 +0,0 @@ -{"kty":"EC","crv":"P-256","alg":"ES256","kid":"ikcxvSn8zzbLoIRz2qq6D8OyYi7Qi0o-NhZLTPuf3e4","x":"DRXjgiVzGkQCY0UKKuFj4mAF46l4D_kuwTdWHuMJWnA","y":"OS3dvs7kt24uibavkTM85uBrNOD5Ay27M3ibMFzEfxE"} diff --git a/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml index 95c4559c79..d8bc50488d 100644 --- a/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml +++ b/products/evidence/reference/deployment-targets/environments/production/evidence/governance.yaml @@ -5,11 +5,11 @@ issuer: {id: https://authority.example.org} publication: {serviceId: urn:example:production:evidence-service, title: Production Evidence service, description: Production minimum-disclosure Evidence service, endpointUrl: https://evidence.example.org, jurisdictions: [urn:example:jurisdiction:production]} authentication: kind: oidc-access-token - issuer: https://mint.example.org + issuer: https://issuer.example.org audiences: [evidence.example.org] tokenTypes: [at+jwt] algorithms: [ES256] - jwksUri: https://mint.example.org/.well-known/jwks.json + jwksUri: https://issuer.example.org/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml deleted file mode 100644 index 4e1c24e9bd..0000000000 --- a/products/evidence/reference/deployment-targets/environments/production/mint/clients/example-requester.yaml +++ /dev/null @@ -1,6 +0,0 @@ -clientId: example-requester-production -principal: urn:example:principal:requester -evidenceAudience: https://relying-party.example.org -requesterTags: [example-requester] -keys: - - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-production-key, x: TSc3zB5_xvsDEIkPNxz0gsgSsKKHRGwn5eYDdODh_hk} diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml deleted file mode 100644 index 0c446a14f3..0000000000 --- a/products/evidence/reference/deployment-targets/environments/production/mint/mint.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -validationMode: strict -issuer: https://mint.example.org -listener: {address: 127.0.0.1, port: 8081} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: 9 - timeoutMilliseconds: 2000 -secretProviders: - file: {root: /run/registry-mint/secrets} -audit: {path: /var/lib/registry-mint/production/mint.jsonl, maximumFileBytes: 1073741824, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} -accessTokens: - audiences: [evidence.example.org] - lifetimeSeconds: 300 - claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} -clientAssertion: - audience: https://mint.example.org/token - maximumLifetimeSeconds: 300 - algorithms: [EdDSA, ES256, RS256] -clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json b/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json deleted file mode 100644 index def3da962e..0000000000 --- a/products/evidence/reference/deployment-targets/environments/production/mint/public-keys/5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q.jwk.json +++ /dev/null @@ -1 +0,0 @@ -{"kty":"EC","crv":"P-256","alg":"ES256","kid":"5riE1IWzcA-eKljEUHLqODpNCIOMMCdUP6ohajRoX4Q","x":"go40IuCHDiuiQ-tKhnti7CTFt6ql8hfQIahfS-BIbew","y":"I-hpkfIUz0X0UiEV3JVpLHjGnX05qi75xG1fHKxsGVk"} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl deleted file mode 100644 index 85d40b5b22..0000000000 --- a/products/evidence/reference/deployment-targets/environments/production/transit/policies/mint-signing.hcl +++ /dev/null @@ -1,14 +0,0 @@ -path "transit/keys/mint-signing" { - capabilities = ["read"] -} - -path "transit/sign/mint-signing/sha2-256" { - capabilities = ["update"] - required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] - allowed_parameters = { - "input" = [] - "key_version" = [9] - "marshaling_algorithm" = ["jws"] - "prehashed" = [true] - } -} diff --git a/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl b/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl deleted file mode 100644 index ce2cc169c8..0000000000 --- a/products/evidence/reference/deployment-targets/environments/production/transit/proxy-configs/mint.hcl +++ /dev/null @@ -1,32 +0,0 @@ -pid_file = "/run/registry-mint/transit-proxy.pid" - -vault { - address = "https://vault.example.org:8200" - ca_cert = "/etc/registry-mint/transit/ca.pem" - retry { - num_retries = -1 - } -} - -auto_auth { - method "kubernetes" { - mount_path = "auth/kubernetes" - config = { - role = "registry-mint-production" - token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" - } - } -} - -api_proxy { - use_auto_auth_token = "force" -} - -listener "unix" { - address = "/run/registry-mint/transit-proxy.sock" - tls_disable = true - socket_mode = "0660" - socket_user = "vault" - socket_group = "registry-mint" - require_request_header = true -} diff --git a/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml index fa4b317630..fa002aa12a 100644 --- a/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml +++ b/products/evidence/reference/deployment-targets/environments/staging/evidence/governance.yaml @@ -5,11 +5,11 @@ issuer: {id: https://authority.example.org} publication: {serviceId: urn:example:staging:evidence-service, title: Staging Evidence service, description: Staging minimum-disclosure Evidence service, endpointUrl: https://evidence.staging.example.org, jurisdictions: [urn:example:jurisdiction:staging]} authentication: kind: oidc-access-token - issuer: https://mint.staging.example.org + issuer: https://issuer.staging.example.org audiences: [evidence.staging.example.org] tokenTypes: [at+jwt] algorithms: [ES256] - jwksUri: https://mint.staging.example.org/.well-known/jwks.json + jwksUri: https://issuer.staging.example.org/.well-known/jwks.json principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml b/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml deleted file mode 100644 index b87e9476e0..0000000000 --- a/products/evidence/reference/deployment-targets/environments/staging/mint/clients/example-requester.yaml +++ /dev/null @@ -1,6 +0,0 @@ -clientId: example-requester-staging -principal: urn:example:principal:requester -evidenceAudience: https://relying-party.staging.example.org -requesterTags: [example-requester] -keys: - - {kty: OKP, crv: Ed25519, alg: EdDSA, kid: example-client-staging-key, x: Sm7nQbtGEU8lau5CDY7OwA5iidN4VwXkyRByi91I3ww} diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml b/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml deleted file mode 100644 index 9bafae5377..0000000000 --- a/products/evidence/reference/deployment-targets/environments/staging/mint/mint.yaml +++ /dev/null @@ -1,28 +0,0 @@ -version: 1 -validationMode: strict -issuer: https://mint.staging.example.org -listener: {address: 127.0.0.1, port: 8081} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: transit - unixSocketPath: /run/registry-mint/transit-proxy.sock - mount: transit - keyName: mint-signing - keyVersion: 5 - timeoutMilliseconds: 2000 -secretProviders: - file: {root: /run/registry-mint/secrets} -audit: {path: /var/lib/registry-mint/staging/mint.jsonl, maximumFileBytes: 1073741824, hashKeyRef: secret:file/mint-audit-hmac, hashKeyVersion: 1} -accessTokens: - audiences: [evidence.staging.example.org] - lifetimeSeconds: 300 - claims: {principal: sub, requesterTags: evidence_tags, evidenceAudience: evidence_audience, grantId: evidence_grant_id, grantAuthority: evidence_authority, actor: evidence_actor} -clientAssertion: - audience: https://mint.staging.example.org/token - maximumLifetimeSeconds: 300 - algorithms: [EdDSA, ES256, RS256] -clients: {directory: clients} diff --git a/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json b/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json deleted file mode 100644 index d03a011c10..0000000000 --- a/products/evidence/reference/deployment-targets/environments/staging/mint/public-keys/OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4.jwk.json +++ /dev/null @@ -1 +0,0 @@ -{"kty":"EC","crv":"P-256","alg":"ES256","kid":"OpDYNpGmiwmJssZoDA8LScYy-4pym3al9Fow6MMsFA4","x":"wTS_MckL0oFXqQGmLrHOiULNVRRTZvVt6wm7DEFBpKE","y":"aLHAbRXAbPHwbFh_qF4zefZmnRSiOZmJG3ukpHYELZY"} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl deleted file mode 100644 index e53abf6123..0000000000 --- a/products/evidence/reference/deployment-targets/environments/staging/transit/policies/mint-signing.hcl +++ /dev/null @@ -1,14 +0,0 @@ -path "transit/keys/mint-signing" { - capabilities = ["read"] -} - -path "transit/sign/mint-signing/sha2-256" { - capabilities = ["update"] - required_parameters = ["input", "key_version", "marshaling_algorithm", "prehashed"] - allowed_parameters = { - "input" = [] - "key_version" = [5] - "marshaling_algorithm" = ["jws"] - "prehashed" = [true] - } -} diff --git a/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl b/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl deleted file mode 100644 index 426905ac7f..0000000000 --- a/products/evidence/reference/deployment-targets/environments/staging/transit/proxy-configs/mint.hcl +++ /dev/null @@ -1,32 +0,0 @@ -pid_file = "/run/registry-mint/transit-proxy.pid" - -vault { - address = "https://vault.staging.example.org:8200" - ca_cert = "/etc/registry-mint/transit/ca.pem" - retry { - num_retries = -1 - } -} - -auto_auth { - method "kubernetes" { - mount_path = "auth/kubernetes" - config = { - role = "registry-mint-staging" - token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token" - } - } -} - -api_proxy { - use_auto_auth_token = "force" -} - -listener "unix" { - address = "/run/registry-mint/transit-proxy.sock" - tls_disable = true - socket_mode = "0660" - socket_user = "vault" - socket_group = "registry-mint" - require_request_header = true -} diff --git a/products/evidence/reference/deployment-targets/key_separation.py b/products/evidence/reference/deployment-targets/key_separation.py index a0b8ec0893..efe84bbff9 100644 --- a/products/evidence/reference/deployment-targets/key_separation.py +++ b/products/evidence/reference/deployment-targets/key_separation.py @@ -264,7 +264,7 @@ def check_targets(root: pathlib.Path) -> None: seen: dict[str, pathlib.Path] = {} for environment in environments: - for service in ("evidence", "mint"): + for service in ("evidence",): public_keys = environment / service / "public-keys" for path in _service_keys(public_keys): key = _read_json(path) @@ -285,15 +285,6 @@ def check_targets(root: pathlib.Path) -> None: ) _remember(seen, thumbprint, path) - client_directory = environment / "mint" / "clients" - client_files = sorted(client_directory.glob("*.yaml")) - if not client_files: - raise CheckError( - f"{client_directory}: at least one client registration is required" - ) - for path in client_files: - check_client_file(path, seen) - def main(argv: list[str]) -> int: if len(argv) != 2: @@ -305,7 +296,7 @@ def main(argv: list[str]) -> int: print(error, file=sys.stderr) return 1 print( - "Deployment target service and client public keys are distinct and correctly identified." + "Deployment target Evidence signing keys are distinct and correctly identified." ) return 0 diff --git a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/README.md b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/README.md index def8923d7c..d3b487dc82 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/README.md +++ b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/README.md @@ -80,11 +80,9 @@ before derivation runs, so an absent region can never be read as a region. ## Authentication -Inbound callers present access tokens from the deployment's own issuer. A -deployment with no identity provider runs Registry Mint as that issuer: -Evidence verifies Mint-issued tokens exactly the way it verifies any other OIDC -issuer, and the protected registry API in front of the source data is pointed -at the same issuer. Neither service depends on the other. +Inbound callers present access tokens from the deployment's operated OIDC +issuer. Evidence and the protected registry API verify that issuer under their +independently configured resource-server policies. Outbound, the source authenticates to the registry API with the OAuth 2.0 client-credentials grant against that same issuer, placing the credentials in diff --git a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml index 82607231eb..ee74bc74b6 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/protected-read-evidence/bundle/evidence.yaml @@ -6,11 +6,9 @@ service: trustDomain: urn:gov:example:trust-domain:social-protection issuer: id: urn:gov:example:issuer:residence-authority -# Inbound callers present access tokens from the deployment's own issuer. A -# deployment with no identity provider runs Registry Mint as that issuer: -# Evidence verifies Mint-issued tokens exactly the way it verifies any other -# OIDC issuer, and the protected registry API in front of the source data is -# pointed at the same issuer. Neither service depends on the other. +# Inbound callers present access tokens from the deployment's operated OIDC +# issuer. Evidence and the protected registry API verify that issuer under +# their independently configured resource-server policies. authentication: kind: oidc-access-token issuer: https://tokens.gov.example diff --git a/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml b/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml index ef03d9a3f1..44f900a10b 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml +++ b/products/evidence/reference/request-adapter/deployment-projects/sqlite-extract-evidence/bundle/evidence.yaml @@ -6,10 +6,9 @@ service: trustDomain: urn:gov:example:trust-domain:professional-registration issuer: id: urn:gov:example:issuer:licence-authority -# Inbound callers present access tokens from the deployment's own issuer. A -# deployment with no identity provider runs Registry Mint as that issuer. -# Nothing about that changes with the source transport: the extract is a local -# file, so this is the only credential in the project. +# Inbound callers present access tokens from the deployment's operated OIDC +# issuer. The extract is a local file, so this is the only network credential +# contract in the project. authentication: kind: oidc-access-token issuer: https://tokens.gov.example From 01040fb532e6e7cd830f69e48e8ae6b49f3b1b10 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:27:06 +0700 Subject: [PATCH 049/120] test(breg): use stock issuer for request attachments Signed-off-by: Jeremi Joslin --- .../acceptance/request-attachments/README.md | 2 +- .../breg/scripts/test-request-attachments.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/products/breg/acceptance/request-attachments/README.md b/products/breg/acceptance/request-attachments/README.md index 9b26b6fe2c..caafb3d4a2 100644 --- a/products/breg/acceptance/request-attachments/README.md +++ b/products/breg/acceptance/request-attachments/README.md @@ -16,7 +16,7 @@ From the repository root, build matching binaries and run: ```sh CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ cargo build --locked -p registry-breg --features runtime -p registry-bregctl \ - -p registry-mint --bins + --bins python3 products/breg/scripts/test-request-attachments.py python3 products/breg/scripts/test-request-attachments.py --verification ``` diff --git a/products/breg/scripts/test-request-attachments.py b/products/breg/scripts/test-request-attachments.py index f6849c5d8b..9876ea4c40 100755 --- a/products/breg/scripts/test-request-attachments.py +++ b/products/breg/scripts/test-request-attachments.py @@ -54,14 +54,14 @@ def read_runtime_yaml(path: str) -> dict: def test_request_attachment_journey() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--bin-dir", type=Path, default=ROOT / "target/debug", - help="Directory containing matching bregctl, breg and mint binaries") + help="Directory containing matching bregctl and breg binaries") parser.add_argument("--project", type=Path, default=FIXTURE) parser.add_argument("--verification", action="store_true", help="Exercise asynchronous external verification and quarantine (requires PyYAML)") parser.add_argument("--keep", action="store_true", help="Keep owner-only reports after success") args = parser.parse_args() binaries = args.bin_dir.resolve() - for name in ("bregctl", "breg", "mint"): + for name in ("bregctl", "breg"): require((binaries / name).is_file(), f"Build the matching {name} binary first") docker = shutil.which("docker") require(docker is not None, "Docker is required by the native development lifecycle") @@ -99,12 +99,12 @@ def cli(label: str, *arguments: str) -> dict: try: cli("check", "check", str(project)) - breg_port, mint_port, database_port = free_ports() + breg_port, issuer_port, database_port = free_ports() # Native dev owns separate TLS PostgreSQL test/live databases, exact # runtime/migration roles, rehearsal receipt, signed package and activation. started = True state = cli("dev-start", "dev", str(project), "--breg-port", str(breg_port), - "--mint-port", str(mint_port), "--database-port", str(database_port), + "--issuer-port", str(issuer_port), "--database-port", str(database_port), "--docker-bin", str(docker)) require(state.get("status") == "ready" and not state.get("activationPending"), "Native dev did not finish schema rehearsal and activation") @@ -112,9 +112,13 @@ def cli(label: str, *arguments: str) -> dict: cli("verify", "verify", "--runtime-config", runtime) print("Native schema-test, package activation and verify passed.", flush=True) base = state["bregUrl"] - token_files = project / ".breg/dev/secrets" - tokens = {role: (token_files / f"{role}-token").read_text(encoding="utf-8").strip() - for role in ("operator", "owner", "other-owner", "reviewer", "applier")} + tokens = {} + for role in ("operator", "owner", "other-owner", "reviewer", "applier"): + token_report = cli(f"token-{role}", "dev", "token", role, str(project)) + header = Path(token_report["headerFile"]).read_text(encoding="utf-8").strip() + prefix = "Authorization: Bearer " + require(header.startswith(prefix), f"{role} token header is malformed") + tokens[role] = header[len(prefix):] if args.verification: verifier_secret = uuid.uuid4().hex From e8b126f78f5c525ef5824bdedd1dbe184f5de1bf Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:27:42 +0700 Subject: [PATCH 050/120] docs(release): preserve published notes and separate pending migration Signed-off-by: Jeremi Joslin --- release/notes/contextual-authorization.md | 38 +++++++++++++++++++++++ release/notes/v0.30.0.md | 13 ++------ 2 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 release/notes/contextual-authorization.md diff --git a/release/notes/contextual-authorization.md b/release/notes/contextual-authorization.md new file mode 100644 index 0000000000..8caf8f0063 --- /dev/null +++ b/release/notes/contextual-authorization.md @@ -0,0 +1,38 @@ +# Contextual authorization migration notes + +These notes describe unreleased source changes. They do not change the published +v0.30.0 release or select the version of a future release. + +## Issuer and local development + +Registry Mint is removed from current source and future toolsets. Configure an +OAuth issuer independently; maintained local development uses pinned stock +ThunderID 1.0.1. Mint configuration, its access-token signing custody, and its +pre-response issuance audit are not migrated. Evidence assertion signing and +resource audit remain separate requirements. + +Use each product's `dev token` command to obtain a fresh ordinary client token. +Use `dev grant` with an existing Casework-approved grant ID and an explicit, +private connection file for task-bound access. A task grant does not give the +agent the approving person's bearer token or standing access profile. + +## Authorization configuration + +BREG access profiles use `permissions` in place of `grants`. Contextual authority +uses the declared `registry_*` claim mappings and immutable signed task bounds. +Review generated schemas and update runtime/client configuration together. + +Casework templates declare the exact agent, client, resource, scopes, purpose, +subject mapping, bounds, and lifetime. Evidence templates additionally declare +requester tags and the relying-party audience. Officers review these values +before approval; the browser cannot supply replacement policy or selectors. + +Task-bound BREG mutations use governed change requests and fresh Casework status +checks. Evidence reads enforce the signed grant deadline locally. Re-exchanging +an access token cannot extend that deadline, even when the issuer gives the +JWT a later expiry. Revocation prevents new Casework assertions; already issued +read tokens retain only their documented, bounded validity window. + +Citizen federation is deferred and is not part of this delivery. eSignet keeps +its identity-provider role and existing BREG authentication/claim contract; its +provider migration replaces the Mint-specific block with `token_client`. diff --git a/release/notes/v0.30.0.md b/release/notes/v0.30.0.md index d8a693fd37..26ab1d508a 100644 --- a/release/notes/v0.30.0.md +++ b/release/notes/v0.30.0.md @@ -6,20 +6,13 @@ capability coverage, and updates Evidence and Casework configuration workflows. ## Compatibility and migration -- Registry Mint is retired. This release ships no Mint binary or container. - Configure a standards-based OAuth issuer; the maintained local development - commands use pinned stock ThunderID 1.0.1. Mint configuration is not migrated. - This replacement does not preserve Mint external signing-key custody or its - durable pre-response issuance audit. Deployments requiring those properties - must select an issuer that supplies them before upgrading. - - Registry Casework is a new product in this release. The pinned - `casework-install.sh` installs matching `casework` and `caseworkctl` + `casework-install.sh` installs matching `casework`, `caseworkctl`, and `mint` binaries. A Casework deployment needs its own PostgreSQL database, token verification, and explicit access profiles. A source-backed project also needs a governed Base Registry Engine source and - a configured source profile. Configure an OAuth issuer for deployed access; - `caseworkctl dev` uses pinned stock ThunderID 1.0.1 for local development. + a configured source profile. Use Mint when the deployment has no identity + provider; `caseworkctl dev` provides a maintained local-development path. - Casework operator configuration uses `casework-runtime/v1alpha1`. Set an absolute `package.root` for the authored `casework.yaml`, put network fields under `listener`, configure secret providers explicitly, and use From 1b2d998dfb8070b63a4562b92d613c78da24d270 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:28:50 +0700 Subject: [PATCH 051/120] test(breg): use stock issuers in Evidence composition Signed-off-by: Jeremi Joslin --- products/breg/evidence/README.md | 4 +-- products/breg/evidence/starter/README.md | 2 +- .../breg/evidence/tests/verify-composition.py | 29 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/products/breg/evidence/README.md b/products/breg/evidence/README.md index 589df726a2..38e1127c36 100644 --- a/products/breg/evidence/README.md +++ b/products/breg/evidence/README.md @@ -50,7 +50,7 @@ bregctl dev export-client ./registry --client source \ ``` For the broader copied registry use `--clients-file ./registry/clients.yaml` on its -first start. Both starters use default BReg and Mint ports `8090` and `8091`. +first start. Both starters use default BReg and stock issuer ports `8090` and `8091`. Normal stop/start preserves records, package, and credentials. Do not remove data or change the retained registry's model or client declarations to add Evidence. @@ -74,7 +74,7 @@ through native source diff and update. `--work-dir ` retains the synthetic outputs for inspection. It also checks unmodified default init with the one-question starter and its 11 fixtures. The default run starts no services. -Add `--live`, matching `--breg` and `--mint` binary paths, and available Docker +Add `--live`, a matching `--breg` binary path, and available Docker to execute the retained-record journey. It allocates unused loopback ports, creates and edits a record before Evidence exists, stops normally during setup, and verifies the signed answer after restart. It checks retained record and diff --git a/products/breg/evidence/starter/README.md b/products/breg/evidence/starter/README.md index 8bb8e9aec0..75aa6c4e42 100644 --- a/products/breg/evidence/starter/README.md +++ b/products/breg/evidence/starter/README.md @@ -57,7 +57,7 @@ guided path. After BReg dev has published the dedicated source credentials, rehearse both live services with `evidencectl dev --project . --target ./targets/configured --detach`. -This starts Evidence and a separate caller Mint using generated local authority; +This starts Evidence and a separate stock caller issuer using generated local authority; it reuses the target's source connections and outbound TLS settings. The target's caller authentication and service identity remain the explicit build settings. diff --git a/products/breg/evidence/tests/verify-composition.py b/products/breg/evidence/tests/verify-composition.py index 539e5df44a..f61106b03c 100644 --- a/products/breg/evidence/tests/verify-composition.py +++ b/products/breg/evidence/tests/verify-composition.py @@ -271,8 +271,8 @@ def verify_live(workspace: Path, binaries: dict[str, Path], *, late: bool = Fals environment = dict(os.environ) environment.pop("REGISTRY_EVIDENCE_RUNTIME", None) environment["PATH"] = os.pathsep.join( - [str(binaries["breg"].parent), str(binaries["mint"].parent), - str(binaries["evidence"].parent), environment.get("PATH", "")] + [str(binaries["breg"].parent), str(binaries["evidence"].parent), + environment.get("PATH", "")] ) registry, project = workspace / "registry", workspace / "evidence" candidate, target = workspace / "candidate", project / "targets/configured" @@ -291,7 +291,7 @@ def command(name: str, *args: object, cwd: Path | None = None) -> str: if result.returncode: # Native diagnostics are already secret-free. Never include token stdout. details = result.stderr - if name != "mint" and not details: + if not details: try: details = json.dumps(json.loads(result.stdout).get("diagnostics", [])) except (ValueError, AttributeError): @@ -315,10 +315,12 @@ def request(method: str, url: str, token: str, body: object = None, return response.status, json.loads(response.read()), response.headers def token(session: dict[str, object], client_name: str) -> str: - client = next(item for item in session["clients"] if item["id"] == client_name) - return command("mint", "token", "--url", session["tokenEndpoint"], - "--client-id", Path(client["clientIdFile"]).read_text().strip(), - "--key", client["assertionKeyFile"]).strip() + report = json.loads(command("bregctl", "--format", "json", "dev", "token", + client_name, registry)) + header = Path(report["headerFile"]).read_text().strip() + prefix = "Authorization: Bearer " + assert header.startswith(prefix), "BREG dev token header is malformed" + return header[len(prefix):] fact, answer = ("name", "named") if late else ("status", "active") value = "Synthetic Works" if late else "active" @@ -353,7 +355,7 @@ def token(session: dict[str, object], client_name: str) -> str: try: breg_started = True # Also clean up a partially started owned session. session = json.loads(command("bregctl", "dev", registry, "--breg-port", ports[0], - "--mint-port", ports[1], "--database-port", ports[2], + "--issuer-port", ports[1], "--database-port", ports[2], "--format", "json")) assert not project.exists(), "record must precede Evidence creation" operator = token(session, "operator") @@ -382,7 +384,8 @@ def token(session: dict[str, object], client_name: str) -> str: prior_keys = {str(path.relative_to(state_root)): path.read_bytes() for path in (state_root / "credentials").rglob("*") if path.is_file()} prior_registrations = {str(path.relative_to(state_root)): path.read_bytes() - for path in (state_root / "mint/clients").glob("*.yaml")} + for path in (state_root / "issuer/registry-schema/agents").glob("*") + if path.is_file()} prior_clients = json.loads((state_root / "clients.json").read_text()) def history() -> bytes: @@ -575,7 +578,7 @@ def history() -> bytes: denied[name] = {"status": status, "code": problem["code"]} evidence_started = True command("evidencectl", "dev", "--target", target, "--detach", "--evidence-port", ports[3], - "--mint-port", ports[4], cwd=project) + "--issuer-port", ports[4], cwd=project) command("evidencectl", "request", "prepare", question, "--purpose", "record-verification", "--subject", f"subject@{selector_profile}:code={code}", "--name", "by-code", cwd=project) @@ -622,12 +625,12 @@ def main() -> None: for name in ["bregctl", "evidencectl", "evidence"]: parser.add_argument(f"--{name}", type=Path, default=shutil.which(name)) parser.add_argument("--work-dir", type=Path, help="new private directory to retain test outputs") - parser.add_argument("--live", action="store_true", help="also run retained-record proof with Docker, breg and mint") - for name in ["breg", "mint"]: + parser.add_argument("--live", action="store_true", help="also run retained-record proof with Docker, breg and the stock issuer") + for name in ["breg"]: parser.add_argument(f"--{name}", type=Path, default=shutil.which(name)) args = parser.parse_args() binaries = {} - for name in ["bregctl", "evidencectl", "evidence"] + (["breg", "mint"] if args.live else []): + for name in ["bregctl", "evidencectl", "evidence"] + (["breg"] if args.live else []): path = getattr(args, name) if path is None or not path.is_file(): parser.error(f"provide --{name} with a matching native executable") From 0f7d3c015f1a283b3548219ebfddae8e9e4be8e2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:30:49 +0700 Subject: [PATCH 052/120] test(release): retain published Mint inventory in plan fixtures Signed-off-by: Jeremi Joslin --- release/scripts/test_registry_release_plans.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 3eae5878bf..e20cebeaf7 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -137,8 +137,9 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic "bregctl", "breg-installer", ) - if version_tuple >= CASEWORK_RELEASE_MINIMUM_VERSION: + if version_tuple > CASEWORK_RELEASE_MINIMUM_VERSION: inventory = tuple(name for name in inventory if name != "mint") + if version_tuple >= CASEWORK_RELEASE_MINIMUM_VERSION: inventory += ( "casework", "caseworkctl", @@ -584,6 +585,14 @@ def git_read_state(self) -> dict[str, str | None]: class RegistryReleasePlanTest(unittest.TestCase): + def test_fixture_inventory_preserves_published_mint_release(self) -> None: + owner = _load_registry_release() + for version, includes_mint in (("0.30.0", True), ("0.30.1", False)): + with self.subTest(version=version): + document = manifest(version, "beta-fixture", f"v{version}", "candidate") + self.assertEqual(includes_mint, "mint" in document["artifacts"]) + self.assertEqual([], owner.artifact_inventory_errors(version, document["artifacts"])) + def setUp(self) -> None: self.temporary = tempfile.TemporaryDirectory() self.addCleanup(self.temporary.cleanup) From 2bd6435b19de97d3d72ecec1fe06647dc54bc93a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:31:12 +0700 Subject: [PATCH 053/120] docs: describe external issuer ownership Signed-off-by: Jeremi Joslin --- products/breg/AGENTS.md | 2 +- products/breg/contracts/artifact-inventory.yaml | 2 +- products/evidence/reference/deployment-targets/README.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/products/breg/AGENTS.md b/products/breg/AGENTS.md index f592b8eef5..021ded05e0 100644 --- a/products/breg/AGENTS.md +++ b/products/breg/AGENTS.md @@ -27,7 +27,7 @@ signing, and deployment configuration. At the other edges, Registry Manifest owns portable metadata and DCAT rendering and receives a one-way projection, Registry Relay is a separate read-only -publication boundary, and Registry Mint issues tokens while an operated BReg +publication boundary, and an operated OAuth issuer issues tokens while a BReg runtime stays an independent OAuth resource server. Authoring, signing, and migration authority stay separate: tooling can edit, diff, and check configuration, and cannot mint a package signature or hold the production diff --git a/products/breg/contracts/artifact-inventory.yaml b/products/breg/contracts/artifact-inventory.yaml index fe6bdef80c..7b42e23dac 100644 --- a/products/breg/contracts/artifact-inventory.yaml +++ b/products/breg/contracts/artifact-inventory.yaml @@ -25,7 +25,7 @@ artifacts: - {path: acceptance/business, kind: authored-fixture, state: authored} - {path: acceptance/household-history, kind: authored-fixture, state: authored} - {path: acceptance/spatial-service-sites, kind: authored-fixture, state: authored} - - {path: demo, kind: local-mint-server-demo, state: authored} + - {path: demo, kind: local-issuer-server-demo, state: authored} - {path: quickstart, kind: generic-first-hour-quickstart, state: authored} - {path: generated/authoring/registry-project.schema.json, kind: generated-authoring-schema, state: authored} - {path: generated/runtime/runtime.schema.json, kind: generated-runtime-schema, state: authored} diff --git a/products/evidence/reference/deployment-targets/README.md b/products/evidence/reference/deployment-targets/README.md index 68fb3f25a5..413afc36d6 100644 --- a/products/evidence/reference/deployment-targets/README.md +++ b/products/evidence/reference/deployment-targets/README.md @@ -11,8 +11,8 @@ The examples deliberately repeat complete environment documents. They use no overlays, environment branches, symlinks, or runtime substitutions. Replace the reserved `example.org` identities with controlled endpoints and replace the example public keys with the exact public projections of independently created -environment keys. Evidence signing, Evidence audit, subject binding, and issuer -client keys must all remain distinct. +environment keys. Evidence signing, Evidence audit, and subject-binding keys +must all remain distinct. Run `./check-public-key-separation.sh` after replacing keys. It uses Python 3 and PyYAML to parse client registrations structurally, fingerprints the complete From d57ecd9be7762308aba88caf0ef66cfb58eb621f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:38:11 +0700 Subject: [PATCH 054/120] fix(evidencectl): remove retired Mint installer expectations Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/src/request.rs | 6 ------ crates/registry-evidencectl/tests/install_script.rs | 5 ++--- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/registry-evidencectl/src/request.rs b/crates/registry-evidencectl/src/request.rs index f6c276ae9f..45b658e569 100644 --- a/crates/registry-evidencectl/src/request.rs +++ b/crates/registry-evidencectl/src/request.rs @@ -112,8 +112,6 @@ pub struct PrepareArgs { #[arg(long, hide = true)] evidence_bin: Option, - #[arg(long, hide = true)] - mint_bin: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum, Deserialize, Serialize)] @@ -489,9 +487,6 @@ fn progressive_curl_config( } fn prepare_local(args: PrepareArgs) -> Result { - if args.mint_bin.is_some() { - bail!("--mint-bin was removed with the local Registry Mint lifecycle; the ready dev session owns its pinned issuer") - } validate_request_name(&args.name)?; let ready = dev::load_ready_state(&args.project)?; let (question, subjects) = validate_closed_inputs(&ready, &args)?; @@ -1127,7 +1122,6 @@ mod tests { format: PreparedResponseFormat::SignedJws, project: PathBuf::from("."), evidence_bin: None, - mint_bin: None, } } diff --git a/crates/registry-evidencectl/tests/install_script.rs b/crates/registry-evidencectl/tests/install_script.rs index e78fa64ce5..3795c55bcc 100644 --- a/crates/registry-evidencectl/tests/install_script.rs +++ b/crates/registry-evidencectl/tests/install_script.rs @@ -11,7 +11,7 @@ use tempfile::TempDir; const TEST_VERSION: &str = "v9.8.7"; const TEST_DEV_VERSION: &str = "v9.8.7-dev.12345.2"; -const BINARIES: [&str; 4] = ["evidence", "evidencectl", "mint", "evidence-oid4vci"]; +const BINARIES: [&str; 3] = ["evidence", "evidencectl", "evidence-oid4vci"]; #[cfg(unix)] #[test] @@ -69,7 +69,6 @@ fn installer_help_describes_the_toolset_and_verification_contract() { for expected in [ "evidence runtime", "evidencectl adopter", - "mint token issuer", "evidence-oid4vci wallet delivery", "curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/evidencectl-install.sh | bash", "SHA256SUMS", @@ -302,7 +301,7 @@ fn missing_checksum_entry_refuses_the_whole_install() { fn checksum_failure_preserves_the_existing_toolset() { let fixture = InstallerFixture::new(); fixture.preinstall_previous_toolset(); - fixture.corrupt_release_asset("mint"); + fixture.corrupt_release_asset("evidence-oid4vci"); let output = fixture.run(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); From 3e2841debd6522f710f3db8ed1bce611854ed966 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:38:11 +0700 Subject: [PATCH 055/120] test(relay): name the external acceptance issuer Signed-off-by: Jeremi Joslin --- products/relay-v2/acceptance/civil-event/runtime.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/relay-v2/acceptance/civil-event/runtime.yaml b/products/relay-v2/acceptance/civil-event/runtime.yaml index e904592a0c..88fb2e7de3 100644 --- a/products/relay-v2/acceptance/civil-event/runtime.yaml +++ b/products/relay-v2/acceptance/civil-event/runtime.yaml @@ -8,8 +8,8 @@ sources: path: fixture.sqlite authentication: issuer: - id: registry-mint - discoveryUrl: https://mint.example.invalid/.well-known/openid-configuration + id: institutional-issuer + discoveryUrl: https://issuer.example.invalid/.well-known/openid-configuration audience: relay-civil-events tokenTypes: [at+jwt] algorithms: [ES256] From cc74ebef58bf1ae3965c46cfe8ed91c78914543d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:39:30 +0700 Subject: [PATCH 056/120] test(installers): verify two-command toolsets after Mint retirement Signed-off-by: Jeremi Joslin --- crates/registry-breg/tests/install_script.rs | 16 +++++----- .../registry-casework/tests/install_script.rs | 31 ++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/registry-breg/tests/install_script.rs b/crates/registry-breg/tests/install_script.rs index 1c6be8884b..ab89fbf923 100644 --- a/crates/registry-breg/tests/install_script.rs +++ b/crates/registry-breg/tests/install_script.rs @@ -9,7 +9,7 @@ use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; const TEST_VERSION: &str = "v9.8.7"; -const BINARIES: [&str; 3] = ["breg", "bregctl", "mint"]; +const BINARIES: [&str; 2] = ["breg", "bregctl"]; // Distinguishes fixture roots built within the same process. The wall clock alone is not // enough: macOS reports CLOCK_REALTIME at 1 microsecond resolution, so two fixtures built in @@ -62,7 +62,7 @@ fn failed_atomic_pointer_switch_preserves_the_previous_toolset() { #[test] fn failed_atomic_pointer_switch_preserves_a_command_the_pointer_does_not_carry() { let fixture = InstallerFixture::new(); - fixture.preinstall_pointer_toolset_without_mint(); + fixture.preinstall_pointer_toolset_without_ctl(); // The pointer is already a symbolic link, so no migration precedes the // switch and the switch is the first rename onto it. @@ -81,7 +81,7 @@ fn failed_atomic_pointer_switch_preserves_a_command_the_pointer_does_not_carry() #[test] fn a_command_the_pointer_does_not_carry_is_adopted_after_the_switch() { let fixture = InstallerFixture::new(); - fixture.preinstall_pointer_toolset_without_mint(); + fixture.preinstall_pointer_toolset_without_ctl(); let output = fixture.run(false); @@ -92,7 +92,7 @@ fn a_command_the_pointer_does_not_carry_is_adopted_after_the_switch() { ); fixture.assert_release_toolset_active(); assert!( - fixture.install_dir.join("mint").is_symlink(), + fixture.install_dir.join("bregctl").is_symlink(), "an adopted command must become a stable command link" ); } @@ -298,12 +298,12 @@ exec /bin/mv "${arguments[@]}" } /// A machine an earlier toolset installed through the pointer, carrying a - /// `mint` that another product's installer wrote directly. The pointer is + /// standalone CLI installed outside the pointer. The pointer is /// already a symbolic link, so the one-time migration does not run. - fn preinstall_pointer_toolset_without_mint(&self) { + fn preinstall_pointer_toolset_without_ctl(&self) { let toolset = self.install_dir.join(".breg-toolset.earlier"); fs::create_dir_all(&toolset).unwrap(); - for binary in ["breg", "bregctl"] { + for binary in ["breg"] { let path = toolset.join(binary); fs::write(&path, format!("{binary} previous binary\n")).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); @@ -318,7 +318,7 @@ exec /bin/mv "${arguments[@]}" self.install_dir.join(".breg-current"), ) .unwrap(); - fs::write(self.install_dir.join("mint"), "mint previous binary\n").unwrap(); + fs::write(self.install_dir.join("bregctl"), "bregctl previous binary\n").unwrap(); } fn command(&self) -> Command { diff --git a/crates/registry-casework/tests/install_script.rs b/crates/registry-casework/tests/install_script.rs index 25ba5eee3e..c375059e7d 100644 --- a/crates/registry-casework/tests/install_script.rs +++ b/crates/registry-casework/tests/install_script.rs @@ -9,7 +9,7 @@ use std::process::{Command, Output}; use std::sync::atomic::{AtomicU64, Ordering}; const TEST_VERSION: &str = "v9.8.7"; -const BINARIES: [&str; 3] = ["casework", "caseworkctl", "mint"]; +const BINARIES: [&str; 2] = ["casework", "caseworkctl"]; // Distinguishes fixture roots built within the same process. The wall clock alone is not // enough: macOS reports CLOCK_REALTIME at 1 microsecond resolution, so two fixtures built in @@ -62,7 +62,7 @@ fn failed_atomic_pointer_switch_preserves_the_previous_toolset() { #[test] fn failed_atomic_pointer_switch_preserves_a_command_the_pointer_does_not_carry() { let fixture = InstallerFixture::new(); - fixture.preinstall_pointer_toolset_without_mint(); + fixture.preinstall_pointer_toolset_without_ctl(); // The pointer is already a symbolic link, so no migration precedes the // switch and the switch is the first rename onto it. @@ -105,7 +105,7 @@ fn failed_atomic_pointer_switch_preserves_bregs_mint_link() { } #[test] -fn successful_pointer_switch_adopts_another_products_mint_link() { +fn successful_pointer_switch_preserves_retired_command_owned_by_another_product() { let fixture = InstallerFixture::new(); fixture.preinstall_casework_with_breg_mint(); @@ -119,7 +119,7 @@ fn successful_pointer_switch_adopts_another_products_mint_link() { fixture.assert_release_toolset_active(); assert_eq!( fs::read_link(fixture.install_dir.join("mint")).unwrap(), - PathBuf::from(".casework-current/mint") + PathBuf::from(".breg-current/mint") ); } @@ -128,6 +128,9 @@ fn failed_post_switch_adoption_restores_every_changed_command_and_pointer() { for signal in [None, Some(("INT", 130)), Some(("TERM", 143))] { let fixture = InstallerFixture::new(); fixture.preinstall_casework_with_breg_mint(); + let casework = fixture.install_dir.join("casework"); + fs::remove_file(&casework).unwrap(); + fs::write(&casework, "casework previous binary\n").unwrap(); let caseworkctl = fixture.install_dir.join("caseworkctl"); fs::remove_file(&caseworkctl).unwrap(); fs::write(&caseworkctl, "caseworkctl local wrapper\n").unwrap(); @@ -137,8 +140,8 @@ fn failed_post_switch_adoption_restores_every_changed_command_and_pointer() { let previous_mint_target = fs::read_link(fixture.install_dir.join("mint")).unwrap(); let previous_caseworkctl_mode = fs::metadata(&caseworkctl).unwrap().permissions().mode(); - // `caseworkctl` is adopted first. The injected second adoption moves - // the staged `mint` link into place and then either reports failure or + // Both commands require adoption. The injected second adoption moves + // the staged CLI link into place and then either reports failure or // terminates the installer, so both EXIT paths must roll back. let output = match signal { Some((name, _)) => fixture.run_signalled_command_adoption(2, name), @@ -180,7 +183,7 @@ fn failed_post_switch_adoption_restores_every_changed_command_and_pointer() { #[test] fn a_command_the_pointer_does_not_carry_is_adopted_after_the_switch() { let fixture = InstallerFixture::new(); - fixture.preinstall_pointer_toolset_without_mint(); + fixture.preinstall_pointer_toolset_without_ctl(); let output = fixture.run(false); @@ -191,7 +194,7 @@ fn a_command_the_pointer_does_not_carry_is_adopted_after_the_switch() { ); fixture.assert_release_toolset_active(); assert!( - fixture.install_dir.join("mint").is_symlink(), + fixture.install_dir.join("caseworkctl").is_symlink(), "an adopted command must become a stable command link" ); } @@ -397,12 +400,12 @@ exec /bin/mv "${arguments[@]}" } /// A machine an earlier toolset installed through the pointer, carrying a - /// `mint` that another product's installer wrote directly. The pointer is + /// standalone CLI installed outside the pointer. The pointer is /// already a symbolic link, so the one-time migration does not run. - fn preinstall_pointer_toolset_without_mint(&self) { + fn preinstall_pointer_toolset_without_ctl(&self) { let toolset = self.install_dir.join(".casework-toolset.earlier"); fs::create_dir_all(&toolset).unwrap(); - for binary in ["casework", "caseworkctl"] { + for binary in ["casework"] { let path = toolset.join(binary); fs::write(&path, format!("{binary} previous binary\n")).unwrap(); fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); @@ -417,11 +420,11 @@ exec /bin/mv "${arguments[@]}" self.install_dir.join(".casework-current"), ) .unwrap(); - fs::write(self.install_dir.join("mint"), "mint previous binary\n").unwrap(); + fs::write(self.install_dir.join("caseworkctl"), "caseworkctl previous binary\n").unwrap(); } - /// An existing Casework toolset that carries `mint`, while another product - /// owns the public shared `mint` command through its own toolset pointer. + /// An existing Casework toolset beside a retired Mint command owned by + /// another product. Updating Casework must preserve that unrelated command. fn preinstall_casework_with_breg_mint(&self) { let casework_toolset = self.install_dir.join(".casework-toolset.earlier"); let breg_toolset = self.install_dir.join(".breg-toolset.earlier"); From 9f7837687fe29c759daed17e7e1f1d41e5fe57c6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:40:21 +0700 Subject: [PATCH 057/120] fix(breg): refresh Evidence composition claims Signed-off-by: Jeremi Joslin --- .../breg/evidence/default-starter/targets/local/settings.yaml | 2 -- products/breg/evidence/starter/targets/local/settings.yaml | 2 -- 2 files changed, 4 deletions(-) diff --git a/products/breg/evidence/default-starter/targets/local/settings.yaml b/products/breg/evidence/default-starter/targets/local/settings.yaml index 478be3b1cf..16a98bcfd2 100644 --- a/products/breg/evidence/default-starter/targets/local/settings.yaml +++ b/products/breg/evidence/default-starter/targets/local/settings.yaml @@ -23,8 +23,6 @@ governance: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: diff --git a/products/breg/evidence/starter/targets/local/settings.yaml b/products/breg/evidence/starter/targets/local/settings.yaml index 337c1be5ac..064b334c13 100644 --- a/products/breg/evidence/starter/targets/local/settings.yaml +++ b/products/breg/evidence/starter/targets/local/settings.yaml @@ -23,8 +23,6 @@ governance: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: From 16dba006601e99c88dcc4fa9880ba2c0f38cc9f2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:40:49 +0700 Subject: [PATCH 058/120] test(ci): include Casework Evidence acceptance consumers Signed-off-by: Jeremi Joslin --- .github/scripts/test_ci_changes.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index cf3a921a4a..b2fea48c60 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -518,6 +518,7 @@ def test_discovery_product_material_selects_the_complete_product_gate(self) -> N self.assertEqual( {entry["name"] for entry in outputs["rust_matrix"]["include"]}, { + "casework", "developer-tools", "discovery", "evidence", @@ -1162,17 +1163,14 @@ def test_docs_only_change_skips_rust(self) -> None: self.assertFalse(outputs["docs_archives"]) def test_evidence_code_and_product_contracts_select_its_shards_and_drift_gate(self) -> None: - # A path inside the runtime crate seeds that crate alone. registry-mint - # dev-depends on registry-evidence so its compatibility test proves - # Evidence accepts a minted token. The Discovery client also drives a - # real Evidence router. Changing Evidence must therefore run both test - # consumers. + # A runtime change reaches the real Evidence router consumers, including + # the Casework institutional exchange acceptance through its dev dependency. outputs = classify(self.workspace, ("crates/registry-evidence/src/source.rs",)) self.assertTrue(outputs["evidence_contracts"]) self.assertIn("registry-evidence", outputs["rust_packages"]) self.assertEqual( {entry["name"] for entry in outputs["rust_matrix"]["include"]}, - {"developer-tools", "discovery", "evidence", "mint"}, + {"casework", "developer-tools", "discovery", "evidence"}, ) # A products/evidence path belongs to no crate directory, so it seeds From 2018d91a6980815e06ea254b244115216368b1cb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:41:17 +0700 Subject: [PATCH 059/120] style(cli): format retirement regressions Signed-off-by: Jeremi Joslin --- crates/registry-breg/tests/install_script.rs | 6 +++++- crates/registry-casework/tests/install_script.rs | 6 +++++- crates/registry-evidencectl/src/request.rs | 1 - 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/registry-breg/tests/install_script.rs b/crates/registry-breg/tests/install_script.rs index ab89fbf923..574587d6a4 100644 --- a/crates/registry-breg/tests/install_script.rs +++ b/crates/registry-breg/tests/install_script.rs @@ -318,7 +318,11 @@ exec /bin/mv "${arguments[@]}" self.install_dir.join(".breg-current"), ) .unwrap(); - fs::write(self.install_dir.join("bregctl"), "bregctl previous binary\n").unwrap(); + fs::write( + self.install_dir.join("bregctl"), + "bregctl previous binary\n", + ) + .unwrap(); } fn command(&self) -> Command { diff --git a/crates/registry-casework/tests/install_script.rs b/crates/registry-casework/tests/install_script.rs index c375059e7d..ebb8df5cd1 100644 --- a/crates/registry-casework/tests/install_script.rs +++ b/crates/registry-casework/tests/install_script.rs @@ -420,7 +420,11 @@ exec /bin/mv "${arguments[@]}" self.install_dir.join(".casework-current"), ) .unwrap(); - fs::write(self.install_dir.join("caseworkctl"), "caseworkctl previous binary\n").unwrap(); + fs::write( + self.install_dir.join("caseworkctl"), + "caseworkctl previous binary\n", + ) + .unwrap(); } /// An existing Casework toolset beside a retired Mint command owned by diff --git a/crates/registry-evidencectl/src/request.rs b/crates/registry-evidencectl/src/request.rs index 45b658e569..854caaf151 100644 --- a/crates/registry-evidencectl/src/request.rs +++ b/crates/registry-evidencectl/src/request.rs @@ -111,7 +111,6 @@ pub struct PrepareArgs { #[arg(long, hide = true)] evidence_bin: Option, - } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum, Deserialize, Serialize)] From 0318a74936350c0783a5f8eed71684e47f25742d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:42:38 +0700 Subject: [PATCH 060/120] docs(breg): migrate quickstart to stock issuer Signed-off-by: Jeremi Joslin --- products/breg/quickstart/README.md | 154 +-- products/breg/quickstart/run.sh | 431 +------ products/breg/quickstart/self-test.sh | 188 +-- .../breg/quickstart/support/quickstart.py | 1034 ++--------------- 4 files changed, 178 insertions(+), 1629 deletions(-) diff --git a/products/breg/quickstart/README.md b/products/breg/quickstart/README.md index 8bfee512b3..7e5911f12f 100644 --- a/products/breg/quickstart/README.md +++ b/products/breg/quickstart/README.md @@ -1,151 +1,81 @@ -# Base Registry Engine Generic Quickstart +# Base Registry Engine quickstart -This is the shortest local adopter path for Base Registry Engine. It starts a -domain-neutral registry from `bregctl init`, checks it, runs -disposable PostgreSQL and Registry Mint on loopback, obtains a short-lived Mint -token, posts one record, and reads that record back from Base Registry Engine. +This launcher starts a local Registry project with the maintained `bregctl dev` +lifecycle. It uses the pinned stock ThunderID issuer, private-key JWT clients, +a retained PostgreSQL database, and Base Registry Engine on loopback. Credentials +stay in owner-only files. -The launcher replaces only the initialized project's package identity, with a -local one, before `check`, `test`, and `package`. It adds no model, profile, or -catalogue metadata of its own; everything else the registry exposes comes from -the initialized project. - -Prerequisites are Docker, OpenSSL, Python 3, and `uv`, plus Cargo unless you -pass `--installed`. +Prerequisites are Docker and Python 3, plus Cargo unless released `breg` and +`bregctl` binaries are already installed. ```bash products/breg/quickstart/run.sh ``` -The first run builds `breg`, `bregctl`, and `mint`, then -pulls the pinned PostgreSQL image if Docker does not already have it. With -`--installed`, the launcher skips the build and uses the `breg`, `bregctl`, and -`mint` found on `PATH`, which is how a released install runs it. When the -launcher prints `Base Registry Engine generic quickstart is ready`, leave that -terminal running. +The source run builds `breg` and `bregctl`. Use `--installed` to select the two +commands from `PATH`. The launcher initializes the generic project, starts its +retained dev session, acquires a fresh operator header with `bregctl dev token`, +creates one record, and reads it back. -In another terminal, read the created record: - -```bash -products/breg/quickstart/query.sh get -``` - -Or create and read another generic record: +Leave the launcher running, then use the query helper in another terminal: ```bash +products/breg/quickstart/query.sh list products/breg/quickstart/query.sh all +products/breg/quickstart/query.sh create QS-002 "Another synthetic record" ``` -The helper reads the bearer token from `quickstart/.run/secrets/operator-token`. -It does not put the token on the command line or print it. The launcher writes -the local runtime configuration it used to `.run/runtime.yaml`. - -For a non-interactive check of the full local path, run: +The helper reads `.run/headers/operator.header`. It does not put the bearer token +on the command line or print it. A non-interactive check starts the same services, +exercises the API, and removes the owned dev session: ```bash products/breg/quickstart/run.sh --smoke ``` -## Change-request examples - -The configurable change-request examples use the same local quickstart model: -protected token files, generated runtime configuration, `bregctl` -checks, and HTTP calls against the compiled REST surface. Start with the -structural CLI journey in [`../CHANGE_REQUEST_EXAMPLES.md`](../CHANGE_REQUEST_EXAMPLES.md): -check both fixture directories, inspect `explain change-requests`, then run -`products/breg/scripts/test-change-request-examples.sh --env /path/to/test.env`. -The env file contains `BREG_TEST_DATABASE_URL` and -`BREG_TEST_TLS_CA_PEM_PATH`; the full guide shows the exact file -shape and disposable fixture override flags for local authoring edits. The -script uses the same owner-only runtime config and role token file pattern as -the quickstart and demo paths. +## Spatial service-site fixture -The request action flow is GET-driven. For submit, review, revise, cancel, and -apply, fetch the request record first and use the matching -`request.actions[].ifMatch` value as the action `If-Match` header. Do not reuse -the normal record `ETag` for request actions. - -## Spatial service-site quickstart - -Use `--spatial` to run the synthetic service-site fixture instead of the generic `bregctl init` project: - -```bash -products/breg/quickstart/run.sh --spatial -``` - -The spatial mode keeps the same local Mint, package, apply, TLS-verified database, and Base Registry Engine path as the generic quickstart. It switches the project source to `products/breg/acceptance/spatial-service-sites`, gives that copied source local package identity, enables a PostGIS database image, seeds synthetic service-site records, and leaves the server running for QGIS. - -Spatial mode uses the pinned image `postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6`, which is the `17-3.5` image. On Apple Silicon the launcher passes `--platform linux/amd64`, so Docker Desktop may run it under emulation. The ordinary generic path still uses the plain pinned PostgreSQL image. - -The database bootstrap keeps PostGIS admin-owned in `registry_spatial_ext`. The local admin step installs the extension, revokes `CREATE` from public, migration, and runtime roles, revokes all public access on the extension schema, and grants schema usage only to migration, runtime, and the non-login bbox helper role. The quickstart bbox role is `registry_quickstart_runtime__spatial_bbox`; it is `NOLOGIN`, owns only generated candidate-ID views, and has no permanent `CREATE` privilege. It is granted to the migration role only with `INHERIT FALSE`, `SET TRUE`, and `ADMIN FALSE` for governed view ownership transfer. Runtime does not receive bbox role membership. The bbox role does not receive its own database `CONNECT` grant. - -For a non-interactive spatial smoke, run: +The spatial mode copies the maintained synthetic service-site project and adds +explicit stock-issuer clients for its protected profiles. The administrative +client can seed sites. The installation map reader is read-only, carries +`service_zones: central`, and is restricted by the authored bbox limits. ```bash products/breg/quickstart/run.sh --spatial --smoke ``` -That path runs the existing `test`, `package`, `apply`, and server startup flow, then uses synthetic API writes and bbox reads. It checks JSON record listing, GeoJSON output, and the OGC API Features items route for the protected QGIS collection. - -The connection recipe targets QGIS 4.2.1 and GDAL 3.12.4. After `run.sh --spatial` prints ready: - -Start an empty QGIS project and set its project CRS to **OGC:CRS84 (WGS 84 (CRS84))** using the bottom-right CRS control. In the Locator, enter `go 100.55,13.75` and choose the result in the current project CRS. Set the status-bar **Scale** to `1:5000`. Switch the coordinate display to **Extents** and leave a margin below the grant: keep the longitude span below `0.24` degrees and latitude span below `0.19` degrees before adding the layer. The BReg's exact limits remain `0.25` and `0.20`; QGIS coordinate serialization can put an apparently exact-limit extent slightly over its grant. Use `1:2500` if the window makes the extent too wide. A fresh world extent exceeds this fixture's query grant. - -1. Open **Settings > Options > Authentication > Configurations** and **Add a new authentication configuration**. Set a master password if prompted. Give the configuration a recognizable name and set **Resource URL** to the printed **Base Registry Engine** value, without `/v1/gis`. -2. Select **OAuth2 authentication**, **Grant flow: Client Credentials**, and **Resource access token method: Header**. Set **Token URL** to `http://127.0.0.1:/token`, using the printed Mint port, and **Client ID** to `qgis-installation-central`. -3. Read **Client secret** from the printed owner-only file under `quickstart/.run/secrets/qgis-client-secret`. Leave **Scope** empty to use the configured Mint grant and leave **Persist between launches** off for the token session. Save the configuration. Never paste the secret into shell history, logs or project files. -4. Open **Layer > Data Source Manager > WFS / OGC API - Features > New...**. Name the connection and set **URL** to `http://127.0.0.1:/v1/gis`. Select the saved OAuth2 configuration under **Authentication**. Keep GET and feature paging enabled where shown; set **Page size** to `25` for this exercise, then select **OK**. -5. Choose that connection under **Server Connections**, select **Connect**, and select `service-site.installation-map-reader`. This protected collection exercises Mint renewal and the installation's `service_zones: central` restriction. The separate `service-site.map-reader` collection is anonymous. -6. Check **Only request features overlapping the view extent** and select **Add**. This is the provider option `restrictToRequestBBOX=1`; **Page size** maps to `pageSize`. Pan within the fixture's small declared bbox limits rather than requesting the entire world. +This mode seeds the fixture through the authenticated record API, checks the +protected bbox list, and reads the same bounded collection as GeoJSON through +the OGC API Features route. The former QGIS OAuth recipe is retired because the maintained local issuer +accepts private-key JWT clients only. Applications +can copy the public HTTP calls demonstrated by the smoke, while a production +client must use its institution's private-key JWT token provider and keep the +resulting authorization header private. -These controls are described in the [QGIS authentication guide](https://docs.qgis.org/4.2/en/docs/user_manual/auth_system/auth_overview.html) and [WFS / OGC API Features client guide](https://docs.qgis.org/4.2/en/docs/user_manual/working_with_ogc/ogc_client_support.html). A saved layer or project may contain an `authcfg` reference. That reference points to QGIS's authentication database; the client secret must remain in that database or the owner-only quickstart file, never in the layer URL or project content. - -The QGIS principal is an installation client, not a human user. It is read-only and scoped to `service-sites:map.read`, purpose `service-site-map`, principal `synthetic-qgis-installation`, and claim `service_zones: central`. Spatial mode uses 60-second access tokens so the connection exercises Mint renewal. The operator client remains a separate private-key client used for local seed writes. Removing or rotating the QGIS client stops new token renewal, but it does not erase data QGIS has already cached locally. - -The in-process adapter exposes six GIS routes and reuses BReg authorization, read plans and audited GeoJSON output. It advertises an empty OGC API conformance list because it does not implement the full standard. Collection paging reads live data. If a native record request omits the readable geometry with `$select`, GeoJSON represents it as `null`; a profile that cannot read the primary geometry cannot request GeoJSON or bbox. - -To check refresh after a BReg write, leave the launcher running and open another terminal at the repository root. This imports one additional synthetic point through the ordinary authenticated batch API. Run it once per disposable quickstart; its checkpoint makes retries resumable. After a `--installed` run, use the `bregctl` and `mint` on `PATH` in place of the `target/debug/` paths. +To import the optional refresh record while a spatial run remains active, first +acquire a fresh operator header and pass that protected file to `bregctl`: ```bash spatial_run="$PWD/products/breg/quickstart/.run" -spatial_input="$PWD/products/breg/acceptance/spatial-service-sites/fixtures/qgis-refresh-service-site.jsonl" -target/debug/bregctl data validate \ - --package "$spatial_run/build/package" \ - --entity service-site --profile service-site-admin --operation create \ - --input "$spatial_input" - -spatial_refresh_token="$spatial_run/secrets/refresh-token-$(openssl rand -hex 8)" -target/debug/mint token \ - --url "$(cat "$spatial_run/mint-origin")/token" \ - --client-id generic-quickstart \ - --key "$spatial_run/keys/operator/signing-p256-private-jwk" | - python3 products/breg/quickstart/support/quickstart.py \ - store-token --out "$spatial_refresh_token" +target/debug/bregctl --format json dev token operator "$spatial_run/project" \ + >"$spatial_run/refresh-token-report.json" +header_file=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["headerFile"])' \ + "$spatial_run/refresh-token-report.json") target/debug/bregctl data import \ - --package "$spatial_run/build/package" \ + --package "$spatial_run/project/.breg/dev/package" \ --breg-url "$(cat "$spatial_run/breg-origin")" \ - --access-token-file "$spatial_refresh_token" \ + --header-file "$header_file" \ --entity service-site --profile service-site-admin --operation create \ - --input "$spatial_input" \ + --input products/breg/acceptance/spatial-service-sites/fixtures/qgis-refresh-service-site.jsonl \ --checkpoint "$spatial_run/qgis-refresh-checkpoint.json" ``` -Refresh the QGIS layer and its attribute table. Look for `SVC-QGIS-REFRESH` near longitude `100.550123456`, latitude `13.750123456`, with the derived `mapLabel` attribute. Pan within the allowed extent and refresh again after a minute to exercise token renewal. The operator token stays in an owner-only file and is never copied into QGIS. - -For the offline structural self-test, which does not start Docker or use the -network, run: +Run the offline structural check with: ```bash products/breg/quickstart/self-test.sh ``` -All generated configuration, keys, tokens, logs, package artifacts, and -database URLs live under `quickstart/.run/`, which is ignored by Git and created -owner-only. A new run replaces only that quickstart-owned directory after -checking that it is not a symbolic link. - -This is deliberately a local-development route. It uses Mint's supervised -local-development profile, loopback HTTP, disposable PostgreSQL or PostGIS in spatial mode, and an unsigned -local package. Production pilots still require the separate package-signing, -database-role, migration, TLS, and operational lifecycle described in the -product README. +All disposable files are under `quickstart/.run/`. The launcher removes its +owned dev session on exit. Production deployments require an operated issuer, +signer custody, signed packages, and an operated PostgreSQL service. diff --git a/products/breg/quickstart/run.sh b/products/breg/quickstart/run.sh index fccef87f6d..120c705877 100755 --- a/products/breg/quickstart/run.sh +++ b/products/breg/quickstart/run.sh @@ -1,404 +1,53 @@ #!/usr/bin/env bash set -euo pipefail - quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -product_dir=$(cd -- "$quickstart_dir/.." && pwd) -repository_root=$(cd -- "$product_dir/../.." && pwd) -support="$quickstart_dir/support/quickstart.py" +repository_root=$(cd -- "$quickstart_dir/../../.." && pwd) run_dir="$quickstart_dir/.run" -mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" -ordinary_postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' -spatial_postgres_image='postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6' -postgres_image="$ordinary_postgres_image" -postgres_platform=() -spatial_fixture="$product_dir/acceptance/spatial-service-sites" -spatial=false -mode=serve -installed=false - -for argument in "$@"; do - case "$argument" in - --spatial) - if [[ "$spatial" == true ]]; then - printf '%s\n' 'the --spatial option may be supplied only once.' >&2 - exit 2 - fi - spatial=true - postgres_image="$spatial_postgres_image" - postgres_platform=(--platform linux/amd64) - ;; - --smoke) - if [[ "$mode" == smoke ]]; then - printf '%s\n' 'the --smoke option may be supplied only once.' >&2 - exit 2 - fi - mode=smoke - ;; - --installed) - if [[ "$installed" == true ]]; then - printf '%s\n' 'the --installed option may be supplied only once.' >&2 - exit 2 - fi - installed=true - ;; - *) - printf '%s\n' 'usage: products/breg/quickstart/run.sh [--installed] [--spatial] [--smoke]' >&2 - exit 2 - ;; - esac -done - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf '%s\n' "$1 is required for the Base Registry Engine generic quickstart." >&2 - exit 2 - fi -} - -resolve_installed_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf '%s\n' "$1 is required for the Base Registry Engine generic quickstart in --installed mode; $2." >&2 - exit 2 - fi - command -v "$1" -} - -for command in cargo docker openssl python3 uv; do - if [[ "$command" == cargo && "$installed" == true ]]; then - continue - fi - require_command "$command" -done - +support="$quickstart_dir/support/quickstart.py" +spatial=false; smoke=false; installed=false +for arg in "$@"; do case "$arg" in --spatial) spatial=true;; --smoke) smoke=true;; --installed) installed=true;; *) echo 'usage: products/breg/quickstart/run.sh [--installed] [--spatial] [--smoke]' >&2; exit 2;; esac; done +for cmd in docker python3; do command -v "$cmd" >/dev/null || { echo "$cmd is required." >&2; exit 2; }; done if [[ "$installed" == true ]]; then - breg=$(resolve_installed_command breg 'breg-install.sh provides breg and bregctl') - bregctl=$(resolve_installed_command bregctl 'breg-install.sh provides breg and bregctl') - mint=$(resolve_installed_command mint 'breg-install.sh and evidencectl-install.sh both provide mint') - printf '%s\n' '== Using installed breg, bregctl, and mint from PATH' - printf '%s\n' "$breg" - printf '%s\n' "$bregctl" - printf '%s\n' "$mint" -fi - -case "$run_dir" in - "$quickstart_dir/.run") ;; - *) - printf '%s\n' 'quickstart run directory escaped its owned location.' >&2 - exit 2 - ;; -esac -if [[ -L "$run_dir" ]]; then - printf '%s\n' 'quickstart run directory must not be a symbolic link.' >&2 - exit 2 -fi -if [[ -d "$run_dir" ]]; then - rm -rf -- "$run_dir" -elif [[ -e "$run_dir" ]]; then - printf '%s\n' 'quickstart run path exists and is not a directory.' >&2 - exit 2 -fi -umask 077 -mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run_dir/tls" - -# supervision-signal-handling: setup begin -mint_pid="" -breg_pid="" -postgres_container="breg-quickstart-${PPID}-$$" -cleanup() { - if [[ -n "${breg_pid:-}" ]]; then - kill "$breg_pid" >/dev/null 2>&1 || true - wait "$breg_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${mint_pid:-}" ]]; then - kill "$mint_pid" >/dev/null 2>&1 || true - wait "$mint_pid" >/dev/null 2>&1 || true - fi - docker rm -f "$postgres_container" >/dev/null 2>&1 || true -} -trap cleanup EXIT HUP -stop_on_signal() { - printf '\n%s\n' 'Stopping the quickstart services.' - exit 0 -} -trap stop_on_signal INT TERM -# supervision-signal-handling: setup end - -ports=$(python3 "$support" ports) -read -r database_port mint_port breg_port <&2; exit 2; } + bregctl=$(command -v bregctl) || { echo 'bregctl is required in --installed mode.' >&2; exit 2; } +else + command -v cargo >/dev/null || { echo 'cargo is required.' >&2; exit 2; } + export RUSTC_WRAPPER= export CARGO_INCREMENTAL=0 - export CARGO_PROFILE_DEV_DEBUG=0 - export CARGO_PROFILE_TEST_DEBUG=0 - export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" - - printf '%s\n' '== Building Base Registry Engine, its CLI, and Mint' - cargo build --manifest-path "$repository_root/Cargo.toml" --locked \ - -p registry-breg --features registry-breg/runtime \ - -p registry-bregctl \ - -p registry-mint \ - --bins >/dev/null - - breg="$repository_root/target/debug/breg" - bregctl="$repository_root/target/debug/bregctl" - mint="$repository_root/target/debug/mint" + cargo build --manifest-path "$repository_root/Cargo.toml" --locked -p registry-breg --features registry-breg/runtime -p registry-bregctl --bins >/dev/null + breg="$repository_root/target/debug/breg"; bregctl="$repository_root/target/debug/bregctl" fi - +[[ ! -L "$run_dir" ]] || { echo 'quickstart run directory must not be a symbolic link.' >&2; exit 2; } +rm -rf -- "$run_dir"; umask 077; mkdir -m 700 "$run_dir" "$run_dir/headers" +read -r database_port issuer_port breg_port < <(python3 "$support" ports) if [[ "$spatial" == true ]]; then - printf '%s\n' '== Preparing and checking the spatial service-site Registry project' - python3 "$support" prepare-spatial-project --fixture "$spatial_fixture" --project "$run_dir/project" - "$bregctl" --format json check "$run_dir/project" >"$run_dir/check-report.json" + python3 "$support" prepare-spatial-project --fixture "$repository_root/products/breg/acceptance/spatial-service-sites" --project "$run_dir/project" else - printf '%s\n' '== Initializing and checking a generic Registry project' "$bregctl" --format json init "$run_dir/project" >"$run_dir/init-report.json" - python3 "$support" assert-canonical-project --project "$run_dir/project" - python3 "$support" enrich-local-package --project "$run_dir/project" - "$bregctl" --format json check "$run_dir/project" >"$run_dir/check-report.json" -fi - -printf '%s\n' '== Generating disposable local keys and configuration' -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/mint/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/mint-public.jwk.json" -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/operator/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/operator-public.jwk.json" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/keys/mint/audit-hmac-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/audit-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/cursor-key" -openssl rand -hex 24 >"$run_dir/secrets/database-password" -chmod 600 "$run_dir/secrets/database-password" - -prepare_args=( - python3 "$support" prepare - --root "$run_dir" - --database-port "$database_port" - --mint-port "$mint_port" - --breg-port "$breg_port" -) -if [[ "$spatial" == true ]]; then - qgis_secret_fingerprint=$("$mint" client-secret generate --out "$run_dir/secrets/qgis-client-secret") - chmod 600 "$run_dir/secrets/qgis-client-secret" - prepare_args+=(--spatial --qgis-client-secret-fingerprint "$qgis_secret_fingerprint") -fi -"${prepare_args[@]}" - -openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ - -subj '/CN=Base Registry Engine generic quickstart CA' \ - -keyout "$run_dir/tls/ca.key" -out "$run_dir/tls/ca.pem" >/dev/null 2>&1 -openssl req -new -nodes -newkey rsa:2048 \ - -subj '/CN=localhost' \ - -keyout "$run_dir/tls/server.key" -out "$run_dir/tls/server.csr" >/dev/null 2>&1 -printf '%s\n' 'subjectAltName=DNS:localhost' >"$run_dir/tls/server.ext" -openssl x509 -req -sha256 -days 2 \ - -in "$run_dir/tls/server.csr" \ - -CA "$run_dir/tls/ca.pem" \ - -CAkey "$run_dir/tls/ca.key" \ - -CAcreateserial \ - -extfile "$run_dir/tls/server.ext" \ - -out "$run_dir/tls/server.crt" >/dev/null 2>&1 -chmod 600 "$run_dir/tls/ca.key" "$run_dir/tls/server.key" -chmod 644 "$run_dir/tls/ca.pem" "$run_dir/tls/server.crt" - -if [[ "$spatial" == true ]]; then - printf '%s\n' '== Starting disposable PostGIS 17-3.5 with TLS' -else - printf '%s\n' '== Starting disposable PostgreSQL 17 with TLS' -fi -docker run --detach --name "$postgres_container" \ - --env-file "$run_dir/database/postgres.env" \ - --publish "127.0.0.1:${database_port}:5432" \ - "${postgres_platform[@]}" \ - "$postgres_image" >"$run_dir/postgres-container-id" - -for attempt in $(seq 1 120); do - if [[ "$(docker exec "$postgres_container" cat /proc/1/comm)" == postgres ]] && - docker exec "$postgres_container" pg_isready -q -U postgres; then - break - fi - if [[ "$attempt" -eq 120 ]]; then - printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 - exit 1 - fi - sleep 0.25 -done - -postgres_data_directory=$(docker exec "$postgres_container" sh -c 'printf %s "$PGDATA"') -case "$postgres_data_directory" in - /var/lib/postgresql/*) ;; - *) - printf '%s\n' 'PostgreSQL reported an unsafe data directory.' >&2 - exit 1 - ;; -esac -if [[ "$postgres_data_directory" == *..* ]]; then - printf '%s\n' 'PostgreSQL data directory contains parent traversal.' >&2 - exit 1 -fi -docker cp "$run_dir/tls/server.crt" "$postgres_container:$postgres_data_directory/server.crt" -docker cp "$run_dir/tls/server.key" "$postgres_container:$postgres_data_directory/server.key" -docker exec --user root "$postgres_container" sh -eu -c ' - chown postgres:postgres "$1/server.crt" "$1/server.key" - chmod 644 "$1/server.crt" - chmod 600 "$1/server.key" - printf "\nssl = on\nssl_cert_file = '\''server.crt'\''\nssl_key_file = '\''server.key'\''\n" >> "$1/postgresql.conf" - sed -i "s/^host /hostssl /" "$1/pg_hba.conf" -' sh "$postgres_data_directory" -docker exec --user postgres "$postgres_container" \ - pg_ctl -D "$postgres_data_directory" reload >/dev/null - -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d postgres \ - <"$run_dir/database/bootstrap.sql" -docker exec "$postgres_container" createdb -U postgres registry_quickstart_test -docker exec "$postgres_container" createdb -U postgres registry_quickstart -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_quickstart_test \ - <"$run_dir/database/initialize.sql" -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_quickstart \ - <"$run_dir/database/initialize-runtime.sql" - -printf '%s\n' '== Starting Registry Mint for local schema-test credentials' -"$mint" serve --config "$run_dir/mint/mint.yaml" >"$run_dir/logs/mint.log" 2>&1 & -mint_pid=$! -python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 - -"$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id generic-quickstart \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/schema-test-token" -if [[ "$spatial" != true ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id record-reader-quickstart \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/reader-schema-test-token" -fi -if [[ "$spatial" == true ]]; then - python3 "$support" mint-client-secret-token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id qgis-installation-central \ - --secret "$run_dir/secrets/qgis-client-secret" \ - --out "$run_dir/secrets/map-schema-test-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id directory-reader-quickstart \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/directory-schema-test-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id site-reader-quickstart \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/site-schema-test-token" fi - -printf '%s\n' '== Testing, packaging, and activating the local Registry' -export SSL_CERT_FILE="$run_dir/tls/ca.pem" -"$bregctl" --format json test "$run_dir/project" \ - --runtime-config "$run_dir/runtime-test.yaml" \ - --credentials "$run_dir/schema-test-credentials.yaml" \ - --database-id generic-registry-local-db \ - --output "$run_dir/schema-test-receipt.json" \ - >"$run_dir/test-report.json" -schema_fingerprint=$(python3 "$support" json-field --path "$run_dir/test-report.json" --field schemaFingerprint) - -"$bregctl" --format json package "$run_dir/project" \ - --database-id generic-registry-local-db \ - --schema-fingerprint "$schema_fingerprint" \ - --test-receipt "$run_dir/schema-test-receipt.json" \ - --output "$run_dir/build" \ - >"$run_dir/package-report.json" -package_revision=$(python3 "$support" json-field --path "$run_dir/package-report.json" --field packageRevision) -render_runtime_args=(python3 "$support" render-runtime --root "$run_dir" --revision "$package_revision") +cleanup(){ "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true; } +trap cleanup EXIT HUP INT TERM +"$bregctl" --format json dev start --breg-bin "$breg" --docker-bin "$(command -v docker)" --breg-port "$breg_port" --issuer-port "$issuer_port" --database-port "$database_port" "$run_dir/project" >"$run_dir/dev-report.json" +printf 'http://127.0.0.1:%s\n' "$breg_port" >"$run_dir/breg-origin" +client=operator +"$bregctl" --format json dev token "$client" "$run_dir/project" >"$run_dir/token-report.json" +python3 - "$run_dir/token-report.json" "$run_dir/headers/operator.header" <<'PY' +import json,os,shutil,sys +source=json.load(open(sys.argv[1]))['headerFile']; shutil.copyfile(source,sys.argv[2]); os.chmod(sys.argv[2],0o600) +PY if [[ "$spatial" == true ]]; then - render_runtime_args+=(--spatial) -fi -"${render_runtime_args[@]}" - -"$bregctl" apply \ - --runtime-config "$run_dir/runtime.yaml" \ - --package "$run_dir/build/package" \ - --initial >/dev/null -"$bregctl" verify --runtime-config "$run_dir/runtime.yaml" >/dev/null - -printf '%s\n' '== Starting Base Registry Engine on loopback' -BREG_LOG=error "$breg" --config "$run_dir/runtime.yaml" \ - >"$run_dir/logs/breg.log" 2>&1 & -breg_pid=$! -python3 "$support" wait-http --url "http://127.0.0.1:${breg_port}/ready" --timeout 30 - -printf '%s\n' '== Obtaining a short-lived operator token from Registry Mint' -operator_token_name=operator-token -if [[ "$spatial" == true ]]; then - operator_token_name="operator-token-$(openssl rand -hex 8)" -fi -"$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id generic-quickstart \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/$operator_token_name" - -if [[ "$spatial" == true ]]; then - printf '%s\n' '== Obtaining a short-lived QGIS installation token from Registry Mint' - python3 "$support" mint-client-secret-token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id qgis-installation-central \ - --secret "$run_dir/secrets/qgis-client-secret" \ - --out "$run_dir/secrets/map-token" - - printf '%s\n' '== Seeding and checking spatial bbox and GeoJSON reads' - python3 "$support" spatial-smoke \ - --root "$run_dir" \ - --seed "$spatial_fixture/fixtures/seed-service-sites.jsonl" \ - --operator-token-name "$operator_token_name" - - printf '\n%s\n' 'Base Registry Engine spatial service-site quickstart is ready.' - printf ' Base Registry Engine: http://127.0.0.1:%s\n' "$breg_port" - printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" - printf ' Project: %s\n' "$run_dir/project" - printf ' Runtime config: %s\n' "$run_dir/runtime.yaml" - printf ' QGIS OAPIF URL: http://127.0.0.1:%s/v1/gis\n' "$breg_port" - printf ' Collection: service-site.installation-map-reader\n' - printf ' QGIS client id: qgis-installation-central\n' - printf ' QGIS secret: %s\n' "$run_dir/secrets/qgis-client-secret" - printf ' Operator token: %s\n' "$run_dir/secrets/$operator_token_name" - printf ' Map token: %s\n' "$run_dir/secrets/map-token" - printf ' Logs: %s\n' "$run_dir/logs" - - if [[ "$mode" == smoke ]]; then - printf '%s\n' 'Base Registry Engine spatial quickstart smoke passed.' - exit 0 - fi + "$bregctl" --format json dev token installation-map-reader "$run_dir/project" >"$run_dir/map-token-report.json" + python3 - "$run_dir/map-token-report.json" "$run_dir/headers/installation-map-reader.header" <<'PY' +import json,os,shutil,sys +source=json.load(open(sys.argv[1]))['headerFile']; shutil.copyfile(source,sys.argv[2]); os.chmod(sys.argv[2],0o600) +PY + python3 "$support" spatial-smoke --root "$run_dir" --seed "$repository_root/products/breg/acceptance/spatial-service-sites/fixtures/qgis-service-sites.jsonl" else - printf '%s\n' '== Posting and reading one generic record' - created_id=$(python3 "$support" request --root "$run_dir" --action create --code QS-001 --label 'Quickstart example record') - python3 "$support" request --root "$run_dir" --action get --record-id "$created_id" >"$run_dir/created-record.json" - - printf '\n%s\n' 'Base Registry Engine generic quickstart is ready.' - printf ' Base Registry Engine: http://127.0.0.1:%s\n' "$breg_port" - printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" - printf ' Project: %s\n' "$run_dir/project" - printf ' Runtime config: %s\n' "$run_dir/runtime.yaml" - printf ' Operator token: %s\n' "$run_dir/secrets/operator-token" - printf ' Created record: %s\n' "$created_id" - printf ' GET helper: %s get %s\n' "$quickstart_dir/query.sh" "$created_id" - printf ' Logs: %s\n' "$run_dir/logs" - - if [[ "$mode" == smoke ]]; then - printf '%s\n' 'Base Registry Engine generic quickstart smoke passed.' - exit 0 - fi -fi - -printf '\n%s\n' 'Leave this terminal running. Press Ctrl-C to stop the services.' -# supervision-signal-handling: wait begin -while kill -0 "$mint_pid" >/dev/null 2>&1 && kill -0 "$breg_pid" >/dev/null 2>&1; do - sleep 1 -done -printf '%s\n' "A quickstart service stopped unexpectedly; inspect $run_dir/logs." >&2 -exit 1 -# supervision-signal-handling: wait end + id=$(python3 "$support" request --root "$run_dir" --action create --code QS-001 --label 'Quickstart record') + python3 "$support" request --root "$run_dir" --action get --record-id "$id" >/dev/null +fi +echo 'Base Registry Engine quickstart is ready.' +echo " Base Registry Engine: http://127.0.0.1:$breg_port" +echo " Operator header: $run_dir/headers/operator.header" +[[ "$smoke" == true ]] && exit 0 +while :; do sleep 1; done diff --git a/products/breg/quickstart/self-test.sh b/products/breg/quickstart/self-test.sh index 5736a6257f..fb25cea788 100755 --- a/products/breg/quickstart/self-test.sh +++ b/products/breg/quickstart/self-test.sh @@ -1,185 +1,11 @@ #!/usr/bin/env bash set -euo pipefail - quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -product_dir=$(cd -- "$quickstart_dir/.." && pwd) - -bash -n "$quickstart_dir/run.sh" -bash -n "$quickstart_dir/query.sh" +bash -n "$quickstart_dir/run.sh" "$quickstart_dir/query.sh" python3 -m py_compile "$quickstart_dir/support/quickstart.py" -python3 "$quickstart_dir/support/quickstart.py" self-test --quickstart-dir "$quickstart_dir" - -# Extracts the setup and wait regions a launcher marks with -# "# supervision-signal-handling: begin/end" comments, then drives -# the real code through both an operator SIGINT and a genuine crash without -# starting Docker, Mint, or BReg: the region is spliced into a harness -# script that stands in placeholder "sleep" processes for mint_pid/breg_pid. -check_supervision_signal_handling() { - local launcher="$1" - local label="$2" - local preamble="${3:-}" - - local setup_block wait_block - setup_block=$(sed -n '/^# supervision-signal-handling: setup begin$/,/^# supervision-signal-handling: setup end$/p' "$launcher") - wait_block=$(sed -n '/^# supervision-signal-handling: wait begin$/,/^# supervision-signal-handling: wait end$/p' "$launcher") - if [[ -z "$setup_block" ]]; then - printf 'FAIL: %s is missing the supervision-signal-handling setup markers\n' "$label" >&2 - exit 1 - fi - if [[ -z "$wait_block" ]]; then - printf 'FAIL: %s is missing the supervision-signal-handling wait markers\n' "$label" >&2 - exit 1 - fi - - local stub_bin harness_dir run_dir - stub_bin=$(mktemp -d) - harness_dir=$(mktemp -d) - run_dir="$harness_dir/run" - mkdir -p "$run_dir" - cat >"$stub_bin/docker" <<'DOCKER_STUB' -#!/usr/bin/env bash -exit 1 -DOCKER_STUB - chmod +x "$stub_bin/docker" - - # Operator stop: SIGINT must shut the placeholder services down and exit 0. - local operator_script="$harness_dir/operator-stop.sh" - { - printf '#!/usr/bin/env bash\n' - printf 'set -euo pipefail\n' - printf 'run_dir=%q\n' "$run_dir" - [[ -n "$preamble" ]] && printf '%s\n' "$preamble" - printf '%s\n' "$setup_block" - printf 'sleep 100 &\n' - printf 'mint_pid=$!\n' - printf 'printf %%s "$mint_pid" >%q\n' "$harness_dir/mint.pid" - printf 'sleep 100 &\n' - printf 'breg_pid=$!\n' - printf 'printf %%s "$breg_pid" >%q\n' "$harness_dir/breg.pid" - printf '%s\n' "$wait_block" - } >"$operator_script" - chmod +x "$operator_script" - - local operator_stderr="$harness_dir/operator-stop.stderr" - # Job control keeps the background process from inheriting SIGINT as - # ignored, which is bash's default for asynchronous commands and would - # otherwise make the trap below untestable from this non-interactive script. - set -m - PATH="$stub_bin:$PATH" bash "$operator_script" >/dev/null 2>"$operator_stderr" & - local harness_pid=$! - set +m - sleep 0.3 - kill -INT "$harness_pid" - local status=0 - wait "$harness_pid" || status=$? - if [[ "$status" -ne 0 ]]; then - printf 'FAIL: %s did not exit 0 on SIGINT (exit %s)\n' "$label" "$status" >&2 - cat "$operator_stderr" >&2 - exit 1 - fi - if grep -q 'stopped unexpectedly' "$operator_stderr"; then - printf 'FAIL: %s reported an unexpected stop on operator SIGINT\n' "$label" >&2 - exit 1 - fi - local mint_child breg_child - mint_child=$(cat "$harness_dir/mint.pid") - breg_child=$(cat "$harness_dir/breg.pid") - if kill -0 "$mint_child" >/dev/null 2>&1 || kill -0 "$breg_child" >/dev/null 2>&1; then - printf 'FAIL: %s left a placeholder service running after SIGINT\n' "$label" >&2 - exit 1 - fi - - # Genuine crash: the supervision loop must still exit 1 and report it. - local crash_script="$harness_dir/crash.sh" - { - printf '#!/usr/bin/env bash\n' - printf 'set -euo pipefail\n' - printf 'run_dir=%q\n' "$run_dir" - [[ -n "$preamble" ]] && printf '%s\n' "$preamble" - printf '%s\n' "$setup_block" - printf 'sleep 100 &\n' - printf 'mint_pid=$!\n' - printf 'sleep 100 &\n' - printf 'breg_pid=$!\n' - printf 'kill "$breg_pid"\n' - printf 'wait "$breg_pid" 2>/dev/null || true\n' - printf '%s\n' "$wait_block" - } >"$crash_script" - chmod +x "$crash_script" - - local crash_stderr="$harness_dir/crash.stderr" - status=0 - PATH="$stub_bin:$PATH" bash "$crash_script" >/dev/null 2>"$crash_stderr" || status=$? - if [[ "$status" -ne 1 ]]; then - printf 'FAIL: %s did not exit 1 on a genuine crash (exit %s)\n' "$label" "$status" >&2 - cat "$crash_stderr" >&2 - exit 1 - fi - if ! grep -q 'stopped unexpectedly' "$crash_stderr"; then - printf 'FAIL: %s did not report the crash\n' "$label" >&2 - exit 1 - fi - - rm -rf "$stub_bin" "$harness_dir" -} - -check_supervision_signal_handling "$quickstart_dir/run.sh" "quickstart run.sh" -check_supervision_signal_handling "$product_dir/demo/run.sh" "demo run.sh" 'webhook=false' - -# Proves --installed mode fails before Docker, cargo, or any other setup work -# when breg, bregctl, or mint are missing from PATH. The stub PATH holds a -# real dirname, since the launchers use it to locate their own directory -# before any preflight check runs, plus stub docker, openssl, python3, and uv -# commands that exit non-zero if ever invoked. A pass here proves the -# reported failure comes from the missing installed binaries, not from a -# stub standing in for a tool the preflight also needs. -check_installed_missing_binaries() { - local launcher="$1" - local label="$2" - - local stub_bin real_dirname bash_bin - stub_bin=$(mktemp -d) - real_dirname=$(command -v dirname) - bash_bin=$(command -v bash) - ln -s "$real_dirname" "$stub_bin/dirname" - for tool in docker openssl python3 uv; do - cat >"$stub_bin/$tool" <&2 -exit 1 -STUB - chmod +x "$stub_bin/$tool" - done - - local stderr_file status - stderr_file=$(mktemp) - status=0 - PATH="$stub_bin" "$bash_bin" "$launcher" --installed >/dev/null 2>"$stderr_file" || status=$? - if [[ "$status" -eq 0 ]]; then - printf 'FAIL: %s --installed did not fail with breg, bregctl, and mint absent from PATH\n' "$label" >&2 - exit 1 - fi - if ! grep -q 'breg' "$stderr_file"; then - printf 'FAIL: %s --installed did not name the missing breg binary\n' "$label" >&2 - cat "$stderr_file" >&2 - exit 1 - fi - if ! grep -q 'breg-install.sh' "$stderr_file"; then - printf 'FAIL: %s --installed did not point to breg-install.sh\n' "$label" >&2 - cat "$stderr_file" >&2 - exit 1 - fi - if grep -q 'must not run in this test' "$stderr_file"; then - printf 'FAIL: %s --installed invoked a stubbed preflight command before failing\n' "$label" >&2 - cat "$stderr_file" >&2 - exit 1 - fi - - rm -rf "$stub_bin" - rm -f "$stderr_file" -} - -check_installed_missing_binaries "$quickstart_dir/run.sh" "quickstart run.sh" -check_installed_missing_binaries "$product_dir/demo/run.sh" "demo run.sh" - -printf '%s\n' 'Base Registry Engine generic quickstart self-test passed' +rg -q 'bregctl" --format json dev start' "$quickstart_dir/run.sh" +rg -q 'dev token "\$client"' "$quickstart_dir/run.sh" +if rg -ni 'registry[ -]mint|registry-mint|clientAuthentication:|mint[_-](port|bin|origin)' "$quickstart_dir/run.sh" "$quickstart_dir/support/quickstart.py"; then + echo 'quickstart still contains a retired issuer implementation' >&2; exit 1 +fi +echo 'Base Registry Engine quickstart structural self-test passed.' diff --git a/products/breg/quickstart/support/quickstart.py b/products/breg/quickstart/support/quickstart.py index 38e3ddbcea..85c3a3f70c 100755 --- a/products/breg/quickstart/support/quickstart.py +++ b/products/breg/quickstart/support/quickstart.py @@ -1,955 +1,99 @@ #!/usr/bin/env python3 -"""Small helpers for the Base Registry Engine local quickstart.""" - from __future__ import annotations - -import argparse -import base64 -import json -import os -import shutil -import socket -import stat -import sys -import time -import urllib.error -import urllib.parse -import urllib.request +import argparse, json, shutil, socket, stat, sys, urllib.error, urllib.parse, urllib.request from pathlib import Path -from typing import Any - - -AUDIENCE = "urn:breg:quickstart" -CLIENT_ID = "generic-quickstart" -READER_CLIENT_ID = "record-reader-quickstart" -DIRECTORY_CLIENT_ID = "directory-reader-quickstart" -SITE_READER_CLIENT_ID = "site-reader-quickstart" -QGIS_CLIENT_ID = "qgis-installation-central" -DATABASE_ID = "generic-registry-local-db" -INSTANCE_ID = "generic_registry_local" -RUNTIME_DATABASE = "registry_quickstart" -TEST_DATABASE = "registry_quickstart_test" -MIGRATION_ROLE = "registry_quickstart_migration" -RUNTIME_ROLE = "registry_quickstart_runtime" -SPATIAL_BBOX_ROLE = "registry_quickstart_runtime__spatial_bbox" -SOURCE_REVISION = "quickstart-source" -OPERATOR_PURPOSE = "registry-operations" -READER_PURPOSE = "registry-reporting" -READER_ROW_BOUNDARY_STATUS = "active" -SPATIAL_OPERATOR_PURPOSE = "service-site-administration" -SPATIAL_MAP_PURPOSE = "service-site-map" -SPATIAL_DIRECTORY_PURPOSE = "service-site-directory" -GENERIC_TOKEN_LIFETIME_SECONDS = 300 -SPATIAL_TOKEN_LIFETIME_SECONDS = 60 - - -# The generic project's journeys use one access profile per step, and each -# profile needs its own credential because their scopes and purposes differ. -GENERIC_PROFILE_TOKENS = { - "operator": "schema-test-token", - "record-reader": "reader-schema-test-token", -} - - -class QuickstartError(RuntimeError): - pass - - -def _write_new(path: Path, content: str, mode: int = 0o600) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) - with os.fdopen(descriptor, "w", encoding="utf-8") as handle: - handle.write(content) - path.chmod(mode) +class QuickstartError(RuntimeError): pass +INSTANCE_ID='generic-quickstart-local' +SOURCE_REVISION='generic-quickstart-local-1' -def _write_json(path: Path, value: Any, mode: int = 0o600) -> None: - _write_new(path, json.dumps(value, sort_keys=True, separators=(",", ":")), mode) - - -def _read_json_object(path: Path) -> dict[str, Any]: - value = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(value, dict): - raise QuickstartError(f"{path.name} must contain one JSON object") - return value - - -def _require_root(root: Path) -> Path: - if root.is_symlink(): - raise QuickstartError("quickstart root must not be a symbolic link") - resolved = root.resolve() - if not resolved.is_dir(): - raise QuickstartError("quickstart root must be an existing ordinary directory") - return resolved - - -def _require_owner_only_regular(path: Path, name: str) -> None: - if not path.is_file() or path.is_symlink(): - raise QuickstartError(f"{name} must be an owner-only regular file") - if stat.S_IMODE(path.stat().st_mode) & 0o077: - raise QuickstartError(f"{name} must be an owner-only regular file") - - -def reserve_ports() -> tuple[int, int, int]: - listeners: list[socket.socket] = [] +def ports(): + sockets=[]; values=[] try: for _ in range(3): - listener = socket.socket() - listener.bind(("127.0.0.1", 0)) - listeners.append(listener) - return tuple(listener.getsockname()[1] for listener in listeners) # type: ignore[return-value] + s=socket.socket(); s.bind(('127.0.0.1',0)); sockets.append(s); values.append(s.getsockname()[1]) finally: - for listener in listeners: - listener.close() - - -def _render_claims(claims: dict[str, Any]) -> str: - return "".join( - f" {name}: {json.dumps(value, ensure_ascii=True, separators=(',', ':'))}\n" - for name, value in sorted(claims.items()) - ) - - -def _mint_private_key_client( - client_id: str, - public_key: dict[str, Any], - scopes: list[str], - claims: dict[str, Any], -) -> str: - return ( - f"clientId: {client_id}\n" - f"principal: urn:breg:quickstart:{client_id}\n" - "authorization:\n" - f" scopes: {json.dumps(scopes, separators=(',', ':'))}\n" - " claims:\n" - f"{_render_claims(claims)}" - f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" - ) - - -def _mint_client(public_key: dict[str, Any], spatial: bool) -> str: - if spatial: - return _mint_private_key_client( - CLIENT_ID, - public_key, - ["service-sites:seed"], - { - "registry_principal": "synthetic-service-site-admin", - "registry_purpose": SPATIAL_OPERATOR_PURPOSE, - }, - ) - return _mint_private_key_client( - CLIENT_ID, - public_key, - ["registry:generic:operate"], - { - "registry_principal": "generic-registry-operator", - "registry_purpose": OPERATOR_PURPOSE, - }, - ) - - -def _generic_reader_client(public_key: dict[str, Any]) -> str: - return _mint_private_key_client( - READER_CLIENT_ID, - public_key, - ["registry:generic:read"], - { - "registry_principal": "generic-registry-reader", - "registry_purpose": READER_PURPOSE, - "registry_record_status": READER_ROW_BOUNDARY_STATUS, - }, - ) - - -def _spatial_reader_clients(public_key: dict[str, Any]) -> dict[str, str]: - return { - DIRECTORY_CLIENT_ID: _mint_private_key_client( - DIRECTORY_CLIENT_ID, - public_key, - ["service-sites:directory.read"], - { - "registry_principal": "synthetic-directory-reader", - "registry_purpose": SPATIAL_DIRECTORY_PURPOSE, - }, - ), - SITE_READER_CLIENT_ID: _mint_private_key_client( - SITE_READER_CLIENT_ID, - public_key, - ["service-sites:site.read"], - { - "registry_principal": "synthetic-site-reader", - "registry_purpose": SPATIAL_MAP_PURPOSE, - }, - ), - } - - -def _mint_client_secret_client(fingerprint: str) -> str: - if not fingerprint or any(character.isspace() for character in fingerprint): - raise QuickstartError("QGIS client secret fingerprint must be one non-empty token") - return ( - f"clientId: {QGIS_CLIENT_ID}\n" - f"principal: urn:breg:quickstart:{QGIS_CLIENT_ID}\n" - "authorization:\n" - ' scopes: ["service-sites:map.read"]\n' - " claims:\n" - " registry_principal: synthetic-qgis-installation\n" - f" registry_purpose: {SPATIAL_MAP_PURPOSE}\n" - " service_zones: central\n" - "clientAuthentication:\n" - " method: client-secret\n" - f" secretFingerprints: [{json.dumps(fingerprint)}]\n" - ) - - -def _template_text(root: Path, revision: str, package_root: Path, runtime_database: bool, spatial: bool) -> str: - origin = urllib.parse.urlparse((root / "breg-origin").read_text(encoding="ascii").strip()) - mint_origin = (root / "mint-origin").read_text(encoding="ascii").strip() - if origin.scheme != "http" or origin.hostname != "127.0.0.1" or origin.port is None: - raise QuickstartError("server origin must be exact loopback HTTP") - if not revision.startswith("sha256:") or len(revision) != 71: - raise QuickstartError("package revision must be one SHA-256 identifier") - runtime_ref = "secret:file/runtime-database-url" - migration_ref = "secret:file/migration-database-url" - if not runtime_database: - runtime_ref = "secret:file/test-runtime-database-url" - migration_ref = "secret:file/test-migration-database-url" - allowed_clients = [CLIENT_ID] - if spatial: - allowed_clients.extend([QGIS_CLIENT_ID, DIRECTORY_CLIENT_ID, SITE_READER_CLIENT_ID]) - else: - allowed_clients.append(READER_CLIENT_ID) - allowed_clients_yaml = ", ".join(allowed_clients) - return f"""apiVersion: registry.registrystack.org/breg-runtime/v1alpha1 -kind: BRegRuntimeConfig -listener: - bind: 127.0.0.1:{origin.port} -{f' publicOrigin: http://127.0.0.1:{origin.port}\n' if spatial else ''}identity: - environment: local - instanceId: {INSTANCE_ID} - databaseId: {DATABASE_ID} - databaseInitializationEnvironment: local -secretProviders: - file: - root: {root / 'secrets'} -database: - runtimeUrlRef: {runtime_ref} - migrationUrlRef: {migration_ref} - pool: - maxSize: 4 - roles: - migration: {MIGRATION_ROLE} - runtime: {RUNTIME_ROLE} -package: - root: {package_root} - trustAnchorPath: {root / 'trust-anchor.json'} - compilerSourceRevision: {SOURCE_REVISION} - activeRevision: {revision} - activeSequence: 1 -authentication: - oidc: - issuer: {mint_origin} - audience: {AUDIENCE} - allowedAlgorithm: ES256 - accessTokenType: at+jwt - scopeClaim: scope - scopeSeparator: " " - allowedClients: [{allowed_clients_yaml}] - deniedKids: [] - maxTokenLifetimeSeconds: 300 - leewayMilliseconds: 30000 - jwksSource: - kind: static - documentRef: secret:file/mint-jwks - authorityClaims: - principal: registry_principal - purpose: registry_purpose -audit: - hashKeyRef: secret:file/audit-key -cursor: - secretRef: secret:file/cursor-key -eventDestinations: {{}} -""" - - -def _journey_credentials(root: Path, profile_tokens: dict[str, str]) -> str: - path = root / "project/tests/journeys.yaml" - source = path.read_text(encoding="utf-8") - journey = None - steps: list[tuple[str, str]] = [] - pending: str | None = None - for line in source.splitlines(): - stripped = line.strip() - if stripped.startswith("- id: ") and journey is None: - journey = stripped.removeprefix("- id: ").strip() - elif stripped.startswith("- id: "): - pending = stripped.removeprefix("- id: ").strip() - elif stripped.startswith("accessProfile: ") and pending is not None: - steps.append((pending, stripped.removeprefix("accessProfile: ").strip())) - pending = None - named = {step for step, _ in steps} - if not journey or not {"create-record", "get-record", "list-records"}.issubset(named): - raise QuickstartError("bregctl init changed its generic journey shape") - if pending is not None or len(named) != len(steps): - raise QuickstartError("bregctl init emitted a journey step without one access profile") - unbound = sorted({profile for _, profile in steps} - set(profile_tokens)) - if unbound: - raise QuickstartError( - f"bregctl init added access profiles the quickstart has no client for: {', '.join(unbound)}" - ) - bindings = "\n".join( - f" - {{journeyId: {journey}, stepId: {step}, credential: {{type: bearer, tokenRef: secret:file/{profile_tokens[profile]}}}}}" - for step, profile in steps - ) - return ( - "apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1\n" - "kind: SchemaTestCredentials\n" - "bindings:\n" - f"{bindings}\n" - ) - - -def _spatial_journey_credentials() -> str: - bearer_bindings = { - "create-central-service-site": "schema-test-token", - "create-null-geometry-service-site": "schema-test-token", - "create-edge-service-site": "schema-test-token", - "admin-refuses-coordinate-outside-authored-bounds": "schema-test-token", - "installation-client-sees-own-central-row": "map-schema-test-token", - "installation-client-cannot-see-other-installation-row": "map-schema-test-token", - "hidden-geometry-profile-gets-directory-fields": "directory-schema-test-token", - "get-only-profile-gets-site": "site-schema-test-token", - } - anonymous_bindings = [ - "public-map-reader-lists-public-point-fields", - "public-map-reader-bbox-finds-central-site", - "directory-reader-lists-without-geometry", - "directory-reader-bbox-is-refused", - ] - rendered = [ - "apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1", - "kind: SchemaTestCredentials", - "bindings:", - ] - for step in anonymous_bindings: - rendered.append( - " - {journeyId: service-site-source-profile-smoke, " - f"stepId: {step}, credential: {{type: anonymous}}}}" - ) - for step, token in bearer_bindings.items(): - rendered.append( - " - {journeyId: service-site-source-profile-smoke, " - f"stepId: {step}, credential: {{type: bearer, tokenRef: secret:file/{token}}}}}" - ) - return "\n".join(rendered) + "\n" - - -def _initialize_sql(database: str, spatial: bool) -> str: - statements = [ - "CREATE EXTENSION IF NOT EXISTS btree_gist;", - f"REVOKE ALL ON DATABASE {database} FROM PUBLIC;", - f"GRANT CONNECT ON DATABASE {database} TO {MIGRATION_ROLE}, {RUNTIME_ROLE};", - f"CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_source AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_derived AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_context AUTHORIZATION {MIGRATION_ROLE};", - "REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC;", - ] - if spatial: - statements[1:1] = [ - "CREATE SCHEMA registry_spatial_ext AUTHORIZATION postgres;", - "CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA registry_spatial_ext;", - f"REVOKE CREATE ON DATABASE {database} FROM PUBLIC, {MIGRATION_ROLE}, {RUNTIME_ROLE};", - "REVOKE ALL ON SCHEMA registry_spatial_ext FROM PUBLIC;", - f"GRANT USAGE ON SCHEMA registry_spatial_ext TO {MIGRATION_ROLE}, {RUNTIME_ROLE}, {SPATIAL_BBOX_ROLE};", - ] - return "\n".join(statements) + "\n" - - -def prepare( - root: Path, - database_port: int, - mint_port: int, - breg_port: int, - spatial: bool = False, - qgis_client_secret_fingerprint: str | None = None, -) -> None: - root = _require_root(root) - project = root / "project" - if not (project / "registry.yaml").is_file(): - raise QuickstartError("registry project did not create registry.yaml") - if spatial and not qgis_client_secret_fingerprint: - raise QuickstartError("spatial quickstart requires a QGIS client secret fingerprint") - password = (root / "secrets/database-password").read_text(encoding="ascii").strip() - if not password or any(character not in "0123456789abcdef" for character in password): - raise QuickstartError("database password must be non-empty lowercase hexadecimal") - mint_public = _read_json_object(root / "keys/mint-public.jwk.json") - operator_public = _read_json_object(root / "keys/operator-public.jwk.json") - kid = mint_public.get("kid") - if not isinstance(kid, str) or not kid: - raise QuickstartError("Mint public JWK must carry a key identifier") - mint_origin = f"http://127.0.0.1:{mint_port}" - breg_origin = f"http://127.0.0.1:{breg_port}" - _write_new(root / "mint-origin", mint_origin + "\n") - _write_new(root / "breg-origin", breg_origin + "\n") - _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) - _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) - _write_new(root / f"mint/clients/{CLIENT_ID}.yaml", _mint_client(operator_public, spatial)) - if not spatial: - _write_new( - root / f"mint/clients/{READER_CLIENT_ID}.yaml", - _generic_reader_client(operator_public), - ) - if spatial: - assert qgis_client_secret_fingerprint is not None - _write_new(root / f"mint/clients/{QGIS_CLIENT_ID}.yaml", _mint_client_secret_client(qgis_client_secret_fingerprint)) - for client_id, document in _spatial_reader_clients(operator_public).items(): - _write_new(root / f"mint/clients/{client_id}.yaml", document) - token_lifetime_seconds = SPATIAL_TOKEN_LIFETIME_SECONDS if spatial else GENERIC_TOKEN_LIFETIME_SECONDS - _write_new( - root / "mint/mint.yaml", - f"""version: 1 -validationMode: supervised-local-development -issuer: {mint_origin} -listener: {{address: 127.0.0.1, port: {mint_port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{kid}.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing-p256-private-jwk -secretProviders: - file: {{root: {root / 'keys/mint'}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 10485760 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{AUDIENCE}] - lifetimeSeconds: {token_lifetime_seconds} -clientAssertion: - audience: {mint_origin}/token - maximumLifetimeSeconds: 120 - algorithms: [ES256] + for s in sockets: s.close() + print(*values) + +def replace_package(project: Path): + path=project/'registry.yaml'; source=path.read_text() + start=source.find('\npackage:\n'); end=source.find('\nmanifestProjection:\n') + if start<0 or end<=start: raise QuickstartError('project must contain package before manifestProjection') + package=f'\npackage:\n environment: local\n instanceId: {INSTANCE_ID}\n sequence: 1\n sourceRevision: {SOURCE_REVISION}\n' + path.write_text(source[:start]+package+source[end:]) + +def prepare_spatial(fixture: Path, project: Path): + if fixture.is_symlink() or not fixture.is_dir() or project.exists(): raise QuickstartError('spatial fixture and output must be ordinary paths') + if any(p.is_symlink() for p in fixture.rglob('*')): raise QuickstartError('spatial fixture must not contain symbolic links') + shutil.copytree(fixture, project); replace_package(project) + (project/'dev-clients.yaml').write_text('''version: 1 clients: - directory: clients -""", - ) - encoded_password = urllib.parse.quote(password, safe="") - base = f"localhost:{database_port}" - _write_new( - root / "secrets/test-runtime-database-url", - f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/test-migration-database-url", - f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/runtime-database-url", - f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/migration-database-url", - f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", - 0o600, - ) - _write_new( - root / "database/postgres.env", - f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n", - 0o600, - ) - bootstrap_sql = f"""CREATE ROLE {MIGRATION_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -CREATE ROLE {RUNTIME_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -""" - if spatial: - bootstrap_sql += f"""CREATE ROLE {SPATIAL_BBOX_ROLE} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS; -GRANT {SPATIAL_BBOX_ROLE} TO {MIGRATION_ROLE} WITH INHERIT FALSE, SET TRUE, ADMIN FALSE; -""" - _write_new(root / "database/bootstrap.sql", bootstrap_sql, 0o600) - _write_new(root / "database/initialize.sql", _initialize_sql(TEST_DATABASE, spatial)) - _write_new(root / "database/initialize-runtime.sql", _initialize_sql(RUNTIME_DATABASE, spatial)) - _write_new(root / "trust-anchor.json", "{}") - (root / "empty-package").mkdir(mode=0o755) - _write_new( - root / "runtime-test.yaml", - _template_text(root, "sha256:" + "1" * 64, root / "empty-package", False, spatial), - ) - credentials = _spatial_journey_credentials() if spatial else _journey_credentials(root, GENERIC_PROFILE_TOKENS) - _write_new(root / "schema-test-credentials.yaml", credentials) - - -def assert_canonical_project(project: Path) -> None: - if project.is_symlink() or not project.is_dir(): - raise QuickstartError("project must be an ordinary directory") - path = project / "registry.yaml" - source = path.read_text(encoding="utf-8") - if " purposes: " in source or " actions: " in source: - raise QuickstartError( - "bregctl init emitted legacy access-profile keys; expected requiredPurposes and operations" - ) - if " requiredPurposes: [registry-operations]\n" not in source: - raise QuickstartError("bregctl init output is missing requiredPurposes") - if " requiredScopes: [registry:generic:operate]\n" not in source: - raise QuickstartError("bregctl init output is missing requiredScopes") - if " operations: [create, get, list, patch]\n" not in source: - raise QuickstartError("bregctl init output is missing grant operations") - if " requiredScopes: [registry:generic:read]\n" not in source: - raise QuickstartError("bregctl init output is missing the reader scope") - if " requiredPurposes: [registry-reporting]\n" not in source: - raise QuickstartError("bregctl init output is missing the reader purpose") - - -def enrich_local_package(project: Path) -> None: - if project.is_symlink() or not project.is_dir(): - raise QuickstartError("project must be an ordinary directory") - path = project / "registry.yaml" - source = path.read_text(encoding="utf-8") - if "\nmanifestProjection:\n" not in f"\n{source}": - raise QuickstartError("bregctl init output is missing manifestProjection") - lines = source.splitlines(keepends=True) - starts = [index for index, line in enumerate(lines) if line.rstrip("\n") == "package:"] - if len(starts) != 1: - raise QuickstartError("bregctl init output has no single package identity block") - start = starts[0] - end = start + 1 - while end < len(lines) and lines[end].startswith(" "): - end += 1 - if not any(line.strip().startswith("sourceRevision:") for line in lines[start:end]): - raise QuickstartError("bregctl init package identity has no source revision") - package = ( - "package:\n" - " environment: local\n" - f" instanceId: {INSTANCE_ID}\n" - " sequence: 1\n" - f" sourceRevision: {SOURCE_REVISION}\n" - ) - path.write_text("".join(lines[:start]) + package + "".join(lines[end:]), encoding="utf-8") - - -def prepare_spatial_project(fixture: Path, project: Path) -> None: - if fixture.is_symlink() or not fixture.is_dir(): - raise QuickstartError("spatial fixture must be an ordinary directory") - for child in fixture.rglob("*"): - if child.is_symlink(): - raise QuickstartError("spatial fixture must not contain symbolic links") - if project.exists(): - raise QuickstartError("spatial project output must not already exist") - fixture = fixture.resolve() - project_parent = project.parent.resolve() - if project_parent.is_symlink() or not project_parent.is_dir(): - raise QuickstartError("spatial project parent must be an ordinary directory") - shutil.copytree(fixture, project, symlinks=False) - path = project / "registry.yaml" - source = path.read_text(encoding="utf-8") - start = source.find("\npackage:\n") - end = source.find("\nmanifestProjection:\n") - if start < 0 or end < 0 or end <= start: - raise QuickstartError("spatial fixture must contain package before manifestProjection") - package = ( - "\npackage:\n" - " environment: local\n" - f" instanceId: {INSTANCE_ID}\n" - " sequence: 1\n" - f" sourceRevision: {SOURCE_REVISION}\n" - ) - path.write_text(source[:start] + package + source[end:], encoding="utf-8") - - -def render_runtime(root: Path, revision: str, spatial: bool = False) -> None: - root = _require_root(root) - _write_new(root / "runtime.yaml", _template_text(root, revision, root / "build/package", True, spatial)) - - -def store_token(path: Path, source: bytes) -> None: - if len(source) > 64 * 1024: - raise QuickstartError("Mint returned an oversized token") - try: - value = source.decode("ascii").rstrip("\r\n") - except UnicodeDecodeError as error: - raise QuickstartError("Mint returned a non-ASCII token") from error - if value.count(".") != 2 or any(character.isspace() for character in value): - raise QuickstartError("Mint did not return one compact JWT") - _write_new(path, value, 0o600) - - -def wait_http(url: str, timeout_seconds: float) -> None: - deadline = time.monotonic() + timeout_seconds - last: int | None = None - while time.monotonic() < deadline: - try: - with urllib.request.urlopen(url, timeout=2) as response: - last = response.status - except urllib.error.HTTPError as error: - last = error.code - except Exception: - last = None - if last == 200: - return - time.sleep(0.25) - raise QuickstartError(f"{url} did not become ready; last status was {last}") - - -def json_field(path: Path, field: str) -> None: - value: Any = _read_json_object(path) - for part in field.split("."): - if not isinstance(value, dict): - raise QuickstartError(f"{field} did not resolve to a scalar") - value = value[part] - if not isinstance(value, (str, int, float, bool)): - raise QuickstartError(f"{field} did not resolve to a scalar") - print(value) - - -def _token(root: Path, token_name: str = "operator-token") -> str: - path = root / f"secrets/{token_name}" - _require_owner_only_regular(path, token_name) - value = path.read_text(encoding="ascii").strip() - if value.count(".") != 2: - raise QuickstartError(f"{token_name} does not contain one compact JWT") - return value - - -def _request( - root: Path, - method: str, - path: str, - body: dict[str, Any] | None, - idempotency_key: str | None = None, - expected: int = 200, - token_name: str = "operator-token", - accept: str = "application/json", -) -> dict[str, Any]: - origin = (root / "breg-origin").read_text(encoding="ascii").strip() - headers = {"Accept": accept, "Authorization": f"Bearer {_token(root, token_name)}"} - data = None - if body is not None: - data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() - headers["Content-Type"] = "application/json" - if idempotency_key is not None: - headers["Idempotency-Key"] = idempotency_key - request = urllib.request.Request(origin + path, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(request, timeout=10) as response: - response_bytes = response.read() - status = response.status - except urllib.error.HTTPError as error: - response_bytes = error.read() - status = error.code - if status != expected: - raise QuickstartError(f"{method} {path} returned {status}, expected {expected}") - document = json.loads(response_bytes) if response_bytes else {} - if not isinstance(document, dict): - raise QuickstartError(f"{method} {path} returned a non-object JSON response") - return document - - -def request(root: Path, action: str, code: str | None, label: str | None, record_id: str | None) -> None: - root = _require_root(root) - if action == "create": - if not code or not label: - raise QuickstartError("create requires code and label") - document = _request( - root, - "POST", - "/v1/records/records?accessProfile=operator", - {"data": {"code": code, "label": label}}, - f"quickstart-{code}", - 201, - ) - record = document.get("data") - identifier = record.get("recordIdentifier") if isinstance(record, dict) else None - if not isinstance(identifier, str) or not identifier: - raise QuickstartError("created record has no data.recordIdentifier") - print(identifier) - elif action == "get": - if not record_id: - raise QuickstartError("get requires a record id") - document = _request( - root, - "GET", - f"/v1/records/records/{urllib.parse.quote(record_id, safe='')}?accessProfile=operator", - None, - ) - print(json.dumps(document, indent=2, sort_keys=True)) - elif action == "list": - document = _request(root, "GET", "/v1/records/records?accessProfile=operator&$top=10", None) - print(json.dumps(document, indent=2, sort_keys=True)) - else: - raise QuickstartError("unknown request action") - - -def _seed_payloads(seed: Path) -> list[dict[str, Any]]: - if seed.is_symlink() or not seed.is_file(): - raise QuickstartError("spatial seed input must be an ordinary JSONL file") - payloads: list[dict[str, Any]] = [] - for line_number, line in enumerate(seed.read_text(encoding="utf-8").splitlines(), start=1): - if not line.strip(): - continue - value = json.loads(line) - if not isinstance(value, dict) or value.get("operation") != "create" or not isinstance(value.get("data"), dict): - raise QuickstartError(f"spatial seed line {line_number} must be a create operation with object data") - payloads.append(value["data"]) - if len(payloads) < 200: - raise QuickstartError("spatial seed must contain the multi-page synthetic fixture") - return payloads - - -def spatial_smoke(root: Path, seed: Path, operator_token_name: str = "operator-token") -> None: - root = _require_root(root) - payloads = _seed_payloads(seed) - for index, data in enumerate(payloads, start=1): - _request( - root, - "POST", - "/v1/records/service-sites?accessProfile=service-site-admin", - {"data": data}, - f"quickstart-spatial-{index:03d}", - 201, - operator_token_name, - ) - bbox = urllib.parse.quote("100.45,13.60,100.60,13.80", safe=",") - list_document = _request( - root, - "GET", - f"/v1/records/service-sites?accessProfile=installation-map-reader&bbox={bbox}&$top=25", - None, - token_name="map-token", - ) - rows = list_document.get("items") - if not isinstance(rows, list) or not rows: - raise QuickstartError("spatial bbox record list returned no visible rows") - geojson = _request( - root, - "GET", - f"/v1/records/service-sites?accessProfile=installation-map-reader&bbox={bbox}&$top=25", - None, - token_name="map-token", - accept="application/geo+json", - ) - if geojson.get("type") != "FeatureCollection" or not isinstance(geojson.get("features"), list): - raise QuickstartError("spatial bbox record request did not return a GeoJSON FeatureCollection") - gis = _request( - root, - "GET", - f"/v1/gis/collections/service-site.installation-map-reader/items?bbox={bbox}&limit=25&f=json", - None, - token_name="map-token", - accept="application/geo+json", - ) - if gis.get("type") != "FeatureCollection" or not isinstance(gis.get("features"), list): - raise QuickstartError("QGIS OAPIF items request did not return a GeoJSON FeatureCollection") - - -def mint_client_secret_token(url: str, client_id: str, secret_path: Path, out: Path) -> None: - _require_owner_only_regular(secret_path, "QGIS client secret") - secret = secret_path.read_text(encoding="ascii").strip() - if not secret or any(character.isspace() for character in secret): - raise QuickstartError("QGIS client secret must be one non-empty token") - userpass = f"{urllib.parse.quote(client_id, safe='')}:{urllib.parse.quote(secret, safe='')}" - headers = { - "Authorization": "Basic " + base64.b64encode(userpass.encode("ascii")).decode("ascii"), - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - } - body = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode("ascii") - request = urllib.request.Request(url, data=body, headers=headers, method="POST") + - id: operator + accessProfiles: [service-site-admin] + scopes: [service-sites:seed] + claims: {registry_principal: synthetic-service-site-admin, registry_purpose: service-site-administration} + - id: installation-map-reader + accessProfiles: [installation-map-reader] + scopes: [service-sites:map.read] + claims: {registry_principal: synthetic-qgis-installation, registry_purpose: service-site-map, service_zones: central} + - id: hidden-geometry-reader + accessProfiles: [hidden-geometry-reader] + scopes: [service-sites:directory.read] + claims: {registry_principal: synthetic-directory-reader, registry_purpose: service-site-directory} + - id: get-only-map-reader + accessProfiles: [get-only-map-reader] + scopes: [service-sites:site.read] + claims: {registry_principal: synthetic-site-reader, registry_purpose: service-site-map} +''') + +def authorization(root: Path, name='operator'): + path=root/'headers'/f'{name}.header' + mode=stat.S_IMODE(path.stat().st_mode) + if not path.is_file() or mode & 0o077: raise QuickstartError(f'{name} header must be an owner-only file') + value=path.read_text(encoding='ascii').strip() + if not value.startswith('Authorization: Bearer ') or value.count('.') != 2: raise QuickstartError(f'{name} header is invalid') + return value.split(': ',1)[1] + +def request(root: Path, method: str, path: str, body=None, expected=200, client='operator', idem=None, accept='application/json'): + origin=(root/'breg-origin').read_text().strip(); headers={'Authorization':authorization(root,client),'Accept':accept} + data=None + if body is not None: data=json.dumps(body,separators=(',',':'),sort_keys=True).encode(); headers['Content-Type']='application/json' + if idem: headers['Idempotency-Key']=idem + req=urllib.request.Request(origin+path,data=data,headers=headers,method=method) try: - with urllib.request.urlopen(request, timeout=10) as response: - document = json.loads(response.read()) - status = response.status - except urllib.error.HTTPError as error: - document = json.loads(error.read() or b"{}") - status = error.code - if status != 200 or not isinstance(document, dict): - raise QuickstartError(f"Mint client-secret token request returned {status}") - token = document.get("access_token") - if not isinstance(token, str): - raise QuickstartError("Mint client-secret response did not contain an access_token") - store_token(out, token.encode("ascii")) - - -def self_test(quickstart_dir: Path) -> None: - quickstart_dir = quickstart_dir.resolve() - required = [ - "run.sh", - "query.sh", - "self-test.sh", - ".gitignore", - "support/quickstart.py", - ] - for relative in required: - if not (quickstart_dir / relative).is_file(): - raise QuickstartError(f"missing quickstart file: {relative}") - run_source = (quickstart_dir / "run.sh").read_text(encoding="utf-8") - query_source = (quickstart_dir / "query.sh").read_text(encoding="utf-8") - readme_source = (quickstart_dir / "README.md").read_text(encoding="utf-8") - helper_source = (quickstart_dir / "support/quickstart.py").read_text(encoding="utf-8") - required_needles = [ - ('"$bregctl" --format json init "$run_dir/project"', run_source), - ('assert-canonical-project --project "$run_dir/project"', run_source), - ('enrich-local-package --project "$run_dir/project"', run_source), - ('"$bregctl" --format json check "$run_dir/project"', run_source), - ('"$mint" token', run_source), - ('store-token --out "$run_dir/secrets/$operator_token_name"', run_source), - ("--spatial", run_source), - ("prepare-spatial-project", run_source), - ("postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6", run_source), - ("--platform linux/amd64", run_source), - ("mint-client-secret-token", run_source), - ("spatial-smoke", run_source), - ("--operator-token-name", run_source), - ("operator-token-$(openssl rand -hex 8)", run_source), - ("databaseInitializationEnvironment: local", helper_source), - ("apiVersion: registry.registrystack.org/breg-runtime/v1alpha1", helper_source), - ("kind: BRegRuntimeConfig", helper_source), - ('INSTANCE_ID = "generic_registry_local"', helper_source), - ('SOURCE_REVISION = "quickstart-source"', helper_source), - ("requiredPurposes", helper_source), - ("operations", helper_source), - ("clientAuthentication:", helper_source), - ("secretFingerprints:", helper_source), - ("registry_spatial_ext", helper_source), - ('SPATIAL_BBOX_ROLE = "registry_quickstart_runtime__spatial_bbox"', helper_source), - ("WITH INHERIT FALSE, SET TRUE, ADMIN FALSE", helper_source), - ("SPATIAL_TOKEN_LIFETIME_SECONDS = 60", helper_source), - ('QGIS_CLIENT_ID = "qgis-installation-central"', helper_source), - ("service-site.installation-map-reader", helper_source), - ('--action get', query_source), - ] - for needle, haystack in required_needles: - if needle not in haystack: - raise QuickstartError(f"quickstart structure is missing {needle!r}") - forbidden_needles = [ - ("Authorization: Bearer ${", run_source), - ("TOKEN=", run_source), - ("clientSecret=", readme_source), - ] - for forbidden, haystack in forbidden_needles: - if forbidden in haystack: - raise QuickstartError(f"quickstart leaks token material through {forbidden!r}") - if (quickstart_dir / ".gitignore").read_text(encoding="utf-8").strip() != ".run/": - raise QuickstartError("quickstart disposable state must stay ignored") - removed_references = ( - "canonical" + "ize-project", - "sample" + "-records.jsonl", - "runtime" + "-config.template.yaml", - ) - for removed in removed_references: - if removed in run_source or removed in query_source or removed in readme_source: - raise QuickstartError(f"quickstart still references removed artifact or command: {removed}") - removed_defaults = ( - "jwks" + "Cache:", - "maxAge" + "Seconds:", - "operational" + "Timeouts:", - "wait" + "TimeoutMilliseconds:", - ) - for removed in removed_defaults: - if removed in helper_source: - raise QuickstartError(f"runtime renderer should rely on the default for {removed}") - - -def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser() - commands = result.add_subparsers(dest="command", required=True) - commands.add_parser("ports") - prepare_parser = commands.add_parser("prepare") - prepare_parser.add_argument("--root", required=True, type=Path) - prepare_parser.add_argument("--database-port", required=True, type=int) - prepare_parser.add_argument("--mint-port", required=True, type=int) - prepare_parser.add_argument("--breg-port", required=True, type=int) - prepare_parser.add_argument("--spatial", action="store_true") - prepare_parser.add_argument("--qgis-client-secret-fingerprint") - canonical_project_parser = commands.add_parser("assert-canonical-project") - canonical_project_parser.add_argument("--project", required=True, type=Path) - package_parser = commands.add_parser("enrich-local-package") - package_parser.add_argument("--project", required=True, type=Path) - spatial_project_parser = commands.add_parser("prepare-spatial-project") - spatial_project_parser.add_argument("--fixture", required=True, type=Path) - spatial_project_parser.add_argument("--project", required=True, type=Path) - runtime_parser = commands.add_parser("render-runtime") - runtime_parser.add_argument("--root", required=True, type=Path) - runtime_parser.add_argument("--revision", required=True) - runtime_parser.add_argument("--spatial", action="store_true") - wait_parser = commands.add_parser("wait-http") - wait_parser.add_argument("--url", required=True) - wait_parser.add_argument("--timeout", required=True, type=float) - token_parser = commands.add_parser("store-token") - token_parser.add_argument("--out", required=True, type=Path) - secret_token_parser = commands.add_parser("mint-client-secret-token") - secret_token_parser.add_argument("--url", required=True) - secret_token_parser.add_argument("--client-id", required=True) - secret_token_parser.add_argument("--secret", required=True, type=Path) - secret_token_parser.add_argument("--out", required=True, type=Path) - field_parser = commands.add_parser("json-field") - field_parser.add_argument("--path", required=True, type=Path) - field_parser.add_argument("--field", required=True) - request_parser = commands.add_parser("request") - request_parser.add_argument("--root", required=True, type=Path) - request_parser.add_argument("--action", choices=("create", "get", "list"), required=True) - request_parser.add_argument("--code") - request_parser.add_argument("--label") - request_parser.add_argument("--record-id") - smoke_parser = commands.add_parser("spatial-smoke") - smoke_parser.add_argument("--root", required=True, type=Path) - smoke_parser.add_argument("--seed", required=True, type=Path) - smoke_parser.add_argument("--operator-token-name", default="operator-token") - self_test_parser = commands.add_parser("self-test") - self_test_parser.add_argument("--quickstart-dir", required=True, type=Path) - return result - - -def main() -> int: - args = parser().parse_args() - try: - if args.command == "ports": - print(" ".join(str(port) for port in reserve_ports())) - elif args.command == "prepare": - prepare( - args.root, - args.database_port, - args.mint_port, - args.breg_port, - args.spatial, - args.qgis_client_secret_fingerprint, - ) - elif args.command == "assert-canonical-project": - assert_canonical_project(args.project) - elif args.command == "enrich-local-package": - enrich_local_package(args.project) - elif args.command == "prepare-spatial-project": - prepare_spatial_project(args.fixture, args.project) - elif args.command == "render-runtime": - render_runtime(args.root, args.revision, args.spatial) - elif args.command == "wait-http": - wait_http(args.url, args.timeout) - elif args.command == "store-token": - store_token(args.out, sys.stdin.buffer.read()) - elif args.command == "mint-client-secret-token": - mint_client_secret_token(args.url, args.client_id, args.secret, args.out) - elif args.command == "json-field": - json_field(args.path, args.field) - elif args.command == "request": - request(args.root, args.action, args.code, args.label, args.record_id) - elif args.command == "spatial-smoke": - spatial_smoke(args.root, args.seed, args.operator_token_name) - elif args.command == "self-test": - self_test(args.quickstart_dir) - else: # pragma: no cover - raise AssertionError(args.command) - except (OSError, KeyError, json.JSONDecodeError, QuickstartError) as error: - print(error, file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) + with urllib.request.urlopen(req,timeout=30) as res: status=res.status; payload=res.read() + except urllib.error.HTTPError as err: status=err.code; payload=err.read() + if status!=expected: raise QuickstartError(f'{method} {path} returned {status}, expected {expected}') + return json.loads(payload) if payload else {} + +def generic(root: Path, action: str, code=None, label=None, record_id=None): + if action=='create': + doc=request(root,'POST','/v1/records/records?accessProfile=operator',{'data':{'code':code,'label':label}},201,idem=f'quickstart-{code}') + print(doc['data']['recordIdentifier']) + elif action=='get': print(json.dumps(request(root,'GET',f'/v1/records/records/{urllib.parse.quote(record_id,safe="")}?accessProfile=operator'),indent=2,sort_keys=True)) + elif action=='list': print(json.dumps(request(root,'GET','/v1/records/records?accessProfile=operator&$top=10'),indent=2,sort_keys=True)) + +def spatial_smoke(root: Path, seed: Path): + rows=[json.loads(line)['data'] for line in seed.read_text().splitlines() if line.strip()] + if len(rows)<200: raise QuickstartError('spatial seed is incomplete') + for i,data in enumerate(rows,1): request(root,'POST','/v1/records/service-sites?accessProfile=service-site-admin',{'data':data},201,idem=f'quickstart-spatial-{i:03d}') + bbox='100.45,13.60,100.60,13.80' + doc=request(root,'GET',f'/v1/records/service-sites?accessProfile=installation-map-reader&bbox={bbox}&$top=25',client='installation-map-reader') + if not doc.get('items'): raise QuickstartError('spatial reader returned no rows') + geo=request(root,'GET',f'/v1/gis/collections/service-site.installation-map-reader/items?bbox={bbox}&limit=25&f=json',client='installation-map-reader',accept='application/geo+json') + if geo.get('type')!='FeatureCollection': raise QuickstartError('spatial endpoint returned invalid GeoJSON') + +def main(): + p=argparse.ArgumentParser(); s=p.add_subparsers(dest='cmd',required=True) + s.add_parser('ports') + q=s.add_parser('prepare-spatial-project'); q.add_argument('--fixture',type=Path,required=True); q.add_argument('--project',type=Path,required=True) + q=s.add_parser('request'); q.add_argument('--root',type=Path,required=True); q.add_argument('--action',choices=['create','get','list'],required=True); q.add_argument('--code'); q.add_argument('--label'); q.add_argument('--record-id') + q=s.add_parser('spatial-smoke'); q.add_argument('--root',type=Path,required=True); q.add_argument('--seed',type=Path,required=True) + a=p.parse_args() + if a.cmd=='ports': ports() + elif a.cmd=='prepare-spatial-project': prepare_spatial(a.fixture,a.project) + elif a.cmd=='request': generic(a.root,a.action,a.code,a.label,a.record_id) + else: spatial_smoke(a.root,a.seed) +try: main() +except (QuickstartError,OSError,KeyError,json.JSONDecodeError) as e: print(f'quickstart: {e}',file=sys.stderr); raise SystemExit(1) From 4d7296b3fd80038a0fc21e11bb97ac0b195b739d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:43:35 +0700 Subject: [PATCH 061/120] feat(casework): run governed source-backed dev sessions Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/dev/config.rs | 14 +- .../src/dev/integrations.rs | 339 ++++++++++++++++++ crates/registry-caseworkctl/src/dev/mod.rs | 97 ++++- .../src/dev/public_jwks.rs | 168 +++++++++ crates/registry-caseworkctl/src/dev/tests.rs | 148 ++++++++ .../registry-thunderid-tooling/src/local.rs | 198 +++++++--- products/casework/DEV-SOURCES.md | 138 +++++++ products/casework/README.md | 6 +- 8 files changed, 1053 insertions(+), 55 deletions(-) create mode 100644 crates/registry-caseworkctl/src/dev/integrations.rs create mode 100644 crates/registry-caseworkctl/src/dev/public_jwks.rs create mode 100644 products/casework/DEV-SOURCES.md diff --git a/crates/registry-caseworkctl/src/dev/config.rs b/crates/registry-caseworkctl/src/dev/config.rs index 193b1cb4bf..0216d1a6d5 100644 --- a/crates/registry-caseworkctl/src/dev/config.rs +++ b/crates/registry-caseworkctl/src/dev/config.rs @@ -41,6 +41,8 @@ pub(super) struct Clients { pub clients: Vec, #[serde(default)] pub directory: Vec, + #[serde(default)] + pub integrations: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -388,7 +390,7 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( } // Stable teaching subjects are qualified by the exact local issuer URL. // The container label separately binds the randomly owned dev session. - let description = registry_thunderid_tooling::local::local_description( + let mut description = registry_thunderid_tooling::local::local_description( registry_thunderid_tooling::description::SessionIdentity { label: format!("casework-dev-{}", state.owner), id: "casework-local".into(), @@ -398,6 +400,10 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( state.audience(), local_clients, )?; + if let Some(integrations) = &clients.integrations { + let policy = crate::project::load_and_check_policy(&state.project)?; + integrations.prepare(root, state, &mut description, &policy)?; + } registry_thunderid_tooling::render::render(&description)?; private::create( &root.join("secrets/casework-audit-key"), @@ -463,7 +469,11 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( Zeroizing::new(pem("PRIVATE KEY", &server_key.serialize_der())).as_bytes(), )?; private::create(&root.join("database/pg_hba.conf"), b"local all all trust\nhostnossl all all 0.0.0.0/0 reject\nhostnossl all all ::/0 reject\nhostssl all all 0.0.0.0/0 scram-sha-256\nhostssl all all ::/0 scram-sha-256\n")?; - write_yaml(&root.join("operator.yaml"), &operator(state))?; + let mut operator = operator(state); + if let Some(integrations) = &clients.integrations { + integrations.operator(state, clients, &mut operator)?; + } + write_yaml(&root.join("operator.yaml"), &operator)?; Ok(()) } diff --git a/crates/registry-caseworkctl/src/dev/integrations.rs b/crates/registry-caseworkctl/src/dev/integrations.rs new file mode 100644 index 0000000000..a419e47913 --- /dev/null +++ b/crates/registry-caseworkctl/src/dev/integrations.rs @@ -0,0 +1,339 @@ +//! Explicit source-backed development inputs. The ordinary standalone session +//! remains source-free unless this closed integration block is configured. +use super::{config, private, State}; +use anyhow::{bail, Result}; +use registry_casework_core::CaseworkProject; +use registry_thunderid_tooling::{description::*, local}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct Integrations { + /// Explicit shared audience accepted independently by Casework and each source. + pub resource: String, + #[serde(default)] + pub sources: BTreeMap, + /// Files copied once to source-prefixed references in the retained private root. + #[serde(default)] + pub secret_files: BTreeMap, + #[serde(default)] + pub service_clients: Vec, + #[serde(default)] + pub task_authority: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ServiceClient { + pub id: String, + /// Omission means this Casework session's generated audience. + pub resource: Option, + pub scopes: Vec, + #[serde(default)] + pub claims: BTreeMap, + #[serde(default)] + pub task_exchange: bool, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct TaskAuthority { + pub id: String, + /// Logical authority identifier, independent of the local API transport URL. + pub issuer: String, + /// Public-key-only listener, reachable from the stock issuer container. + pub jwks_port: u16, + pub status_clients: BTreeMap, +} + +impl Integrations { + pub fn validate(&self, clients: &config::Clients, policy: &CaseworkProject) -> Result<()> { + if !registry_platform_httputil::valid_resource_uri(&self.resource) { + bail!("integrations.resource must explicitly name the shared Casework/source audience"); + } + let declared = policy + .sources + .iter() + .map(|source| &source.id) + .collect::>(); + if self.sources.keys().collect::>() != declared { + bail!("source-backed development needs exactly the declared source bindings"); + } + if self.secret_files.len() > 64 || self.service_clients.len() > 32 { + bail!("local integrations exceed their bounded secret or service-client count"); + } + for (name, path) in &self.secret_files { + if !name.starts_with("source-") || !config::identifier(name) || !path.is_absolute() { + bail!( + "source secret inputs need source-prefixed names and absolute owner-only files" + ); + } + } + let mut ids = clients + .clients + .iter() + .map(|client| &client.id) + .collect::>(); + for client in &self.service_clients { + if !config::identifier(&client.id) + || client.id == "issuer" + || !ids.insert(&client.id) + || client.scopes.is_empty() + || client.scopes.len() > 32 + || client.scopes.iter().collect::>().len() != client.scopes.len() + || client.scopes.iter().any(|scope| { + scope.len() > 128 + || scope.contains('*') + || !registry_platform_httputil::valid_scope_token(scope) + }) + || client.resource.as_ref().is_some_and(|resource| { + !registry_platform_httputil::valid_resource_uri(resource) + }) + || client.claims.contains_key("registry_actor_kind") + { + bail!("service clients need distinct IDs, exact resources/scopes and no caller-selected actor marker"); + } + if client.task_exchange + && (self.task_authority.is_none() + || client.resource.is_some() + || client.scopes != ["casework:grants:assert"]) + { + bail!("task-exchange clients receive only casework:grants:assert at this session's Casework audience"); + } + } + if !policy.task_templates.is_empty() && self.task_authority.is_none() { + bail!("governed task templates require an explicit local taskAuthority"); + } + if let Some(authority) = &self.task_authority { + if !config::identifier(&authority.id) + || !authority.issuer.starts_with("https://") + || !registry_platform_httputil::valid_resource_uri(&authority.issuer) + || authority.jwks_port == 0 + || authority.status_clients.len() > 32 + || authority.status_clients.iter().any(|(id, resource)| { + !registry_platform_httputil::valid_resource_uri(resource) + || !self.service_clients.iter().any(|client| { + &client.id == id + && !client.task_exchange + && client.resource.is_none() + && client.scopes == ["casework:grants:status"] + }) + }) + { + bail!("taskAuthority requires a distinct public JWKS port and exact local status clients"); + } + for template in &policy.task_templates { + if !self + .service_clients + .iter() + .any(|client| client.id == template.client && client.task_exchange) + || template.agent.subject != config::principal(&template.client) + { + bail!("task templates must bind a declared task-exchange client and its exact dev identity"); + } + } + } + Ok(()) + } + + pub fn validate_session(&self, state: &State, policy: &CaseworkProject) -> Result<()> { + for binding in self.sources.values() { + let reader = self.service_clients.iter().find(|client| { + binding.client_id_ref == format!("secret:file/service-{}-id", client.id) + && binding.client_assertion_key_ref + == format!("secret:file/service-{}-key", client.id) + && !client.task_exchange + && client.resource.as_deref().unwrap_or(&self.resource) == self.resource + && binding.scopes.as_ref().is_some_and(|scopes| { + scopes.iter().collect::>() + == client.scopes.iter().collect::>() + }) + }); + if reader.is_none() { + bail!("source reader references must select the same generated service client with exactly the binding resource and scopes"); + } + if binding.token_endpoint != format!("{}/oauth2/token", state.issuer_origin()) + || binding.client_assertion_audience.as_deref() + != Some(state.issuer_origin().as_str()) + || binding.resource.as_deref() != Some(self.resource.as_str()) + || binding + .scopes + .as_ref() + .is_none_or(|scopes| scopes.is_empty()) + { + bail!("source bindings must explicitly use the session issuer token endpoint/audience, integrations.resource and reader scopes; configure BREG to admit that resource with independent human profiles/scopes/clients"); + } + } + if let Some(authority) = &self.task_authority { + if [state.casework_port, state.issuer_port, state.database_port] + .contains(&authority.jwks_port) + { + bail!("the public task JWKS listener needs its own distinct port"); + } + } + if policy + .task_templates + .iter() + .any(|template| template.agent.issuer != state.issuer_origin()) + { + bail!("task templates must name this one local session issuer; no second issuer is trusted"); + } + Ok(()) + } + + pub fn prepare( + &self, + root: &Path, + state: &State, + description: &mut IssuerDescription, + policy: &CaseworkProject, + ) -> Result<()> { + self.validate_session(state, policy)?; + for (name, path) in &self.secret_files { + let bytes = zeroize::Zeroizing::new(private::read(path, 64 * 1024)?); + private::create(&root.join("secrets").join(name), &bytes)?; + } + if let Some(authority) = &self.task_authority { + let directory = root.join("task-authority"); + let public = config::keypair(&directory)?; + private::create( + &directory.join("jwks.json"), + &serde_json::to_vec(&json!({"keys":[public]}))?, + )?; + let key = zeroize::Zeroizing::new(private::read( + &directory.join("assertion-key.jwk"), + 64 * 1024, + )?); + private::create(&root.join("secrets/task-authority-signing-key"), &key)?; + description.exchange_issuers.push(ExchangeIssuer { + id: local::agent_id("casework-authority", &authority.id), + name: authority.id.clone(), + issuer: authority.issuer.clone(), + jwks_endpoint: format!( + "http://host.docker.internal:{}/oauth2/jwks", + authority.jwks_port + ), + }); + } + for client in &self.service_clients { + let directory = root.join("credentials").join(&client.id); + let public = config::keypair(&directory)?; + private::create(&directory.join("client-id"), client.id.as_bytes())?; + private::create( + &root + .join("secrets") + .join(format!("service-{}-id", client.id)), + client.id.as_bytes(), + )?; + let key = zeroize::Zeroizing::new(private::read( + &directory.join("assertion-key.jwk"), + 64 * 1024, + )?); + private::create( + &root + .join("secrets") + .join(format!("service-{}-key", client.id)), + &key, + )?; + let resource = client.resource.clone().unwrap_or_else(|| state.audience()); + let server = local::declare_resource(description, &resource, &client.scopes)?; + let mut attributes = client.claims.clone(); + attributes.insert( + "registry_actor_kind".into(), + json!(if client.task_exchange { + "agent" + } else { + "service" + }), + ); + for name in attributes.keys() { + if !description.schema_attributes.contains(name) { + description.schema_attributes.push(name.clone()); + } + } + let agent = config::principal(&client.id); + description.roles.push(Role { + id: local::agent_id("casework-service-role", &client.id), + name: format!("Local {}", client.id), + description: "Explicit local service permissions".into(), + permissions: vec![(server.clone(), client.scopes.clone())], + assigned_agents: vec![agent.clone()], + }); + description.machine_clients.push(MachineClient { + agent_id: agent, + name: format!("Local {}", client.id), + description: "Explicit local service client".into(), + client_id: client.id.clone(), + public_jwks: json!({"keys":[public]}).to_string(), + token_attributes: attributes.keys().cloned().collect(), + attributes, + access_token_lifetime_seconds: 300, + token_exchange: client.task_exchange.then(|| TokenExchangeClient { + assertion_resource_server_id: server, + assertion_scope: "casework:grants:assert".into(), + }), + }); + } + for template in &policy.task_templates { + local::declare_resource(description, &template.resource, &template.scopes)?; + } + description.validate()?; + Ok(()) + } + + pub fn operator( + &self, + state: &State, + clients: &config::Clients, + value: &mut Value, + ) -> Result<()> { + value["sources"] = serde_json::to_value(&self.sources)?; + if let Some(authority) = &self.task_authority { + value["authentication"]["oidc"]["allowedClients"] = json!(clients + .clients + .iter() + .map(|client| client.id.clone()) + .chain(self.service_clients.iter().map(|client| client.id.clone())) + .collect::>()); + value["taskAuthority"] = json!({"id":authority.id,"issuer":authority.issuer, + "exchangeAudience":state.issuer_origin(),"signingKeyRef":"secret:file/task-authority-signing-key", + "statusClients":authority.status_clients}); + } + Ok(()) + } + + pub fn token_parameters(&self, state: &State, id: &str) -> Option<(String, Vec)> { + self.service_clients + .iter() + .find(|client| client.id == id) + .map(|client| { + ( + client.resource.clone().unwrap_or_else(|| state.audience()), + client.scopes.clone(), + ) + }) + } +} + +/// Check the existing adapter contract entirely offline. The staging resolver +/// reads generated secrets before the session is atomically installed. +pub(super) fn validate_bindings(root: &Path, project: &Path) -> Result<()> { + let mut config = registry_casework::RuntimeConfig::load(root.join("operator.yaml"))?; + config + .secret_providers + .file + .as_mut() + .ok_or_else(|| anyhow::anyhow!("local source bindings require generated file secrets"))? + .root = root.join("secrets"); + let secrets = registry_casework::secret_resolver(&config)?; + let policy = crate::project::load_and_check_policy(project)?; + for source in &policy.sources { + config.sources.get(&source.id).ok_or_else(||anyhow::anyhow!("a declared source binding is missing"))? + .build_adapter(source, project, &secrets) + .map_err(|_| anyhow::anyhow!("local source binding is invalid; check its exact BREG event source, reader profile, source description and credential references before starting services"))?; + } + Ok(()) +} diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index b7fafb4227..092cbb3a11 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -6,7 +6,9 @@ //! Docker ID match its private journal. There is intentionally no reset. mod config; +mod integrations; mod private; +mod public_jwks; #[cfg(test)] mod tests; @@ -111,10 +113,19 @@ enum DevAction { Stop(StopArgs), /// Print the bounded retained runtime journal. Events(EventsArgs), - /// Write a fresh bearer header for a registered local teaching client. + /// Write a fresh bearer header for a registered local teaching or service client. Token(TokenArgs), /// Exchange an existing Casework approval using an explicit configured issuer connection. Grant(GrantArgs), + /// Show a stable native subject before authoring local directory or task templates. + Identity(IdentityArgs), +} + +#[derive(Debug, Args)] +struct IdentityArgs { + /// Bounded local client ID to bind in the governed project. + #[arg(value_name = "CLIENT")] + client: String, } #[derive(Debug, Args)] @@ -134,7 +145,7 @@ struct GrantArgs { #[derive(Debug, Args)] struct TokenArgs { - /// Registered local teaching client identifier from the retained dev state. + /// Registered local teaching or service client identifier from the retained dev state. #[arg(value_name = "CLIENT")] client: String, /// Existing authored Casework project directory. @@ -216,6 +227,8 @@ struct State { database_port: u16, clients_file: PathBuf, source_digest: String, + #[serde(default)] + resource: Option, /// Every local client with the access profile it binds and that profile's /// role, recorded so the report needs no second reading of the project. clients: Vec, @@ -297,7 +310,9 @@ impl State { format!("http://127.0.0.1:{}", self.issuer_port) } fn audience(&self) -> String { - format!("urn:casework:dev:{}", self.owner) + self.resource + .clone() + .unwrap_or_else(|| format!("urn:casework:dev:{}", self.owner)) } fn administrator(&self) -> Result<&ReportedClient> { self.clients @@ -331,6 +346,15 @@ pub(crate) fn run(args: DevArgs) -> Result { Some(DevAction::Events(args)) => events(&args.project), Some(DevAction::Token(args)) => fresh_token(&args.project, &args.client), Some(DevAction::Grant(args)) => approved_grant(args), + Some(DevAction::Identity(args)) => { + if !config::identifier(&args.client) || args.client == "issuer" { + bail!("a bounded local client ID is required"); + } + Ok( + json!({"ok":true,"command":"dev identity","clientId":args.client, + "subject":config::principal(&args.client)}), + ) + } Some(DevAction::Start(args)) => start(args), None => start(args.start), } @@ -433,6 +457,10 @@ fn read_state(root: &Path) -> Result { if state.version != 2 || state.root() != root || uuid::Uuid::parse_str(&state.owner).is_err() + || state + .resource + .as_ref() + .is_some_and(|resource| !registry_platform_httputil::valid_resource_uri(resource)) || state.directory_revision < 0 || state .container_id @@ -517,10 +545,12 @@ impl Drop for StartInterruption { fn capture(project: &Path, client_bytes: &[u8]) -> Result { let policy = crate::project::load_and_check_policy(project)?; - if !policy.sources.is_empty() { - bail!("caseworkctl dev serves a project with no declared sources, because every source binding needs a running source system and its own reader credential. Run this project against a deployed Casework runtime, or start with the standalone-decision template"); - } let clients = config::clients(client_bytes)?; + if let Some(integrations) = &clients.integrations { + integrations.validate(&clients, &policy)?; + } else if !policy.sources.is_empty() || !policy.task_templates.is_empty() { + bail!("source-backed development requires explicit integrations with source bindings and any task authority in the local clients file"); + } let bound = config::bind(&clients, &policy)?; let reported = bound .iter() @@ -538,6 +568,13 @@ fn capture(project: &Path, client_bytes: &[u8]) -> Result { hasher.update((bytes.len() as u64).to_be_bytes()); hasher.update(bytes); } + for source in &policy.sources { + let bytes = bounded(&project.join(&source.description), "source description")?; + hasher.update((source.description.len() as u64).to_be_bytes()); + hasher.update(source.description.as_bytes()); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } Ok(Captured { clients, digest: config::hex_lower(&hasher.finalize()), @@ -619,6 +656,10 @@ fn start(args: StartArgs) -> Result { .unwrap_or(55433), clients_file, source_digest: digest, + resource: clients + .integrations + .as_ref() + .map(|value| value.resource.clone()), clients: reported, container_id: None, tls_files_copied: false, @@ -634,9 +675,19 @@ fn start(args: StartArgs) -> Result { for port in [state.casework_port, state.issuer_port, state.database_port] { probe(port)?; } + if let Some(integrations) = &clients.integrations { + integrations + .validate_session(&state, &crate::project::load_and_check_policy(&project)?)?; + if let Some(authority) = &integrations.task_authority { + public_jwks::probe(authority.jwks_port)?; + } + } initialize(&root, &state, &clients)?; read_state(&root)? }; + if clients.integrations.is_some() { + integrations::validate_bindings(&root, &project)?; + } let casework = executable("casework", args.casework_bin.as_deref())?; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or @@ -757,6 +808,9 @@ fn initialize(root: &Path, original: &State, clients: &Clients) -> Result<()> { let mut staged = original.clone(); staged.project = original.project.clone(); config::prepare(&stage, original, clients)?; + if clients.integrations.is_some() { + integrations::validate_bindings(&stage, &original.project)?; + } private::create( &stage.join("state.json"), &serde_json::to_vec_pretty(&staged)?, @@ -1132,8 +1186,19 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { let clients: Clients = serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; let mut children = Children::default(); + let mut public_jwks = None; let result = (|| { ensure_active(&terminate)?; + if let Some(authority) = clients + .integrations + .as_ref() + .and_then(|value| value.task_authority.as_ref()) + { + public_jwks = Some(public_jwks::Server::start( + authority.jwks_port, + &root.join("task-authority/jwks.json"), + )?); + } database(&args.docker_bin, &mut state, &terminate)?; ensure_active(&terminate)?; issuer(&args.docker_bin, &state, &terminate)?; @@ -1194,7 +1259,11 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { if terminate.load(Ordering::Relaxed) { break; } - if children.exited()? { + if children.exited()? + || public_jwks + .as_ref() + .is_some_and(public_jwks::Server::is_finished) + { bail!("a supervised local service exited; inspect private logs"); } match listener.accept() { @@ -1220,6 +1289,7 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { state.save()?; Ok(stop_stream) })(); + drop(public_jwks); let child_cleanup = children.stop(); let issuer_cleanup = stop_issuer(&args.docker_bin, &state); let database_cleanup = stop_database(&args.docker_bin, &state); @@ -3051,10 +3121,17 @@ fn token(state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { let root = state.root(); let clients: Clients = serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; - let client = clients + let (resource, scopes) = clients .clients .iter() .find(|client| client.id == id) + .map(|client| (state.audience(), client.scopes.clone())) + .or_else(|| { + clients + .integrations + .as_ref() + .and_then(|value| value.token_parameters(state, id)) + }) .context("the local client is not registered")?; let bytes = private::read( &root.join("credentials").join(id).join("assertion-key.jwk"), @@ -3072,8 +3149,8 @@ fn token(state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { key, ) .with_audience(state.issuer_origin()) - .with_resource(state.audience()) - .with_scopes(client.scopes.clone()), + .with_resource(resource) + .with_scopes(scopes), )?; let value = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/crates/registry-caseworkctl/src/dev/public_jwks.rs b/crates/registry-caseworkctl/src/dev/public_jwks.rs new file mode 100644 index 0000000000..4096a928fb --- /dev/null +++ b/crates/registry-caseworkctl/src/dev/public_jwks.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Supervisor-owned public keys for the container's external-assertion verifier. +use super::private; +use anyhow::{Context, Result}; +use registry_platform_crypto::PublicJwk; +use serde::{Deserialize, Serialize}; +use std::{ + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct PublicKeys { + keys: Vec, +} + +pub(super) fn probe(port: u16) -> Result<()> { + TcpListener::bind(("0.0.0.0", port)).context("the explicit public JWKS port is occupied")?; + Ok(()) +} + +pub(super) struct Server { + stop: Arc, + thread: Option>, +} +impl Server { + pub fn start(port: u16, file: &Path) -> Result { + // A typed round trip refuses private members before binding a socket. + let keys: PublicKeys = serde_json::from_slice(&private::read(file, 64 * 1024)?)?; + anyhow::ensure!( + !keys.keys.is_empty() && keys.keys.len() <= 8, + "public JWKS needs 1..8 keys" + ); + let body = serde_json::to_vec(&keys)?; + let listener = TcpListener::bind(("0.0.0.0", port)) + .context("cannot bind the explicit public JWKS listener")?; + listener.set_nonblocking(true)?; + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + let thread = thread::spawn(move || { + while !flag.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => { + let _ = respond(stream, &body); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(20)) + } + Err(_) => break, + } + } + }); + Ok(Self { + stop, + thread: Some(thread), + }) + } + pub fn is_finished(&self) -> bool { + self.thread.as_ref().is_none_or(JoinHandle::is_finished) + } +} +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} +fn respond(mut stream: TcpStream, body: &[u8]) -> Result<()> { + stream.set_read_timeout(Some(Duration::from_millis(100)))?; + stream.set_write_timeout(Some(Duration::from_secs(1)))?; + let deadline = Instant::now() + Duration::from_secs(1); + let mut request = Vec::new(); + while request.len() < 4096 && Instant::now() < deadline { + let mut buffer = [0u8; 512]; + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(count) => { + request.extend_from_slice(&buffer[..count]); + if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + break; + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + continue + } + Err(error) => return Err(error.into()), + } + } + let admitted = request.len() <= 4096 + && request.ends_with(b"\r\n\r\n") + && request.split(|byte| *byte == b'\n').next() + == Some(b"GET /oauth2/jwks HTTP/1.1\r".as_slice()); + let (status, body) = if admitted { + ("200 OK", body) + } else { + ("404 Not Found", b"{}".as_slice()) + }; + write!(stream,"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",body.len())?; + stream.write_all(body)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn serves_only_public_keys_and_releases_its_listener() { + let root = tempfile::tempdir().unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let public = super::super::config::keypair(&root.path().join("key")).unwrap(); + let file = root.path().join("jwks.json"); + private::create( + &file, + &serde_json::to_vec(&serde_json::json!({"keys":[public]})).unwrap(), + ) + .unwrap(); + let port = TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port(); + let server = Server::start(port, &file).unwrap(); + for (method, path, expected) in [ + ("GET", "/oauth2/jwks", "200 OK"), + ("POST", "/oauth2/jwks", "404 Not Found"), + ("GET", "/assertion-key.jwk", "404 Not Found"), + ] { + let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!( + socket, + "{method} {path} HTTP/1.1\r\nHost: localhost\r\n\r\n" + ) + .unwrap(); + let mut response = String::new(); + socket.read_to_string(&mut response).unwrap(); + assert!(response.starts_with(&format!("HTTP/1.1 {expected}"))); + assert!(!response.contains("\"d\":")); + if method == "GET" && path == "/oauth2/jwks" { + assert!(response.contains("\"keys\":")); + } + } + drop(server); + probe(port).unwrap(); + private::replace( + &file, + b"{\"keys\":[{\"kty\":\"EC\",\"d\":\"private-canary\"}]}", + ) + .unwrap(); + assert!(Server::start(port, &file).is_err()); + probe(port).unwrap(); + } +} diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index bca738835d..6e806a9d46 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -16,6 +16,7 @@ fn session(project: &Path) -> State { database_port: 55433, clients_file: project.join("dev-clients.yaml"), source_digest: String::new(), + resource: None, clients: Vec::new(), container_id: None, tls_files_copied: false, @@ -3290,6 +3291,7 @@ fn seeding_administrator_token_is_issued_after_every_other_client() { }) .collect(), directory: Vec::new(), + integrations: None, }; state.clients = clients .clients @@ -3349,6 +3351,7 @@ fn token_issuance_stops_between_clients_when_interrupted() { }, ], directory: Vec::new(), + integrations: None, }; state.clients = vec![ ReportedClient { @@ -3458,3 +3461,148 @@ fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { arbitrary.extend(["--purpose", "invented"]); assert!(::try_parse_from(arbitrary).is_err()); } + +#[test] +fn explicit_local_integrations_render_only_governed_authority_and_bind_the_source() { + let workspace = tempfile::tempdir().unwrap(); + let project = standalone(workspace.path()); + let mut policy = crate::project::load_and_check_policy(&project).unwrap(); + policy.sources.push(serde_json::from_value(json!({"id":"source","adapter":"breg","description":"source.json","requests":[{"entity":"correction","queue":"decisions"}]})).unwrap()); + let mut clients = config::clients(STANDALONE_DEV_CLIENTS.as_bytes()).unwrap(); + let integrations: integrations::Integrations = serde_json::from_value(json!({ + "resource":"urn:casework:source-group", + "sources":{"source":{"baseUrl":"http://127.0.0.1:8800","readerProfile":"reader", + "tokenEndpoint":"http://127.0.0.1:8093/oauth2/token","clientAssertionAudience":"http://127.0.0.1:8093", + "resource":"urn:casework:source-group","scopes":["records:get"], + "clientIdRef":"secret:file/service-reader-id","clientAssertionKeyRef":"secret:file/service-reader-key", + "webhookSecretRef":"secret:file/source-webhook","eventSource":"urn:registrystack:registry:source:instance:local"}}, + "serviceClients":[ + {"id":"reader","scopes":["records:get"]}, + {"id":"task-agent","scopes":["casework:grants:assert"],"taskExchange":true}, + {"id":"status","scopes":["casework:grants:status"]}], + "taskAuthority":{"id":"casework","issuer":"https://casework.local.example","jwksPort":8801,"statusClients":{"status":"urn:casework:source-group"}} + })).unwrap(); + clients.integrations = Some(integrations.clone()); + integrations.validate(&clients, &policy).unwrap(); + let mut state = session(&project); + state.resource = Some(integrations.resource.clone()); + integrations.validate_session(&state, &policy).unwrap(); + let root = project.join("private"); + private::directory(&root).unwrap(); + for name in ["issuer", "secrets", "credentials"] { + private::directory(&root.join(name)).unwrap(); + } + let key = config::keypair(&root.join("human")).unwrap(); + let mut description = registry_thunderid_tooling::local::local_description( + registry_thunderid_tooling::description::SessionIdentity { + label: "source-unit".into(), + id: "casework-local".into(), + }, + state.issuer_port, + root.join("issuer"), + state.audience(), + vec![registry_thunderid_tooling::local::LocalClient { + client_id: "staff".into(), + public_jwks: json!({"keys":[key]}).to_string(), + claims: BTreeMap::new(), + scopes: vec!["casework:staff".into()], + allow_human_fixture: true, + }], + ) + .unwrap(); + integrations + .prepare(&root, &state, &mut description, &policy) + .unwrap(); + let agent = description + .machine_clients + .iter() + .find(|client| client.client_id == "task-agent") + .unwrap(); + assert_eq!(agent.agent_id, config::principal("task-agent")); + assert!(agent.token_exchange.is_some()); + assert_eq!(agent.attributes["registry_actor_kind"], "agent"); + assert!(!agent + .attributes + .keys() + .any(|name| name.starts_with("registry_grant_"))); + let mut operator = config::operator(&state); + integrations + .operator(&state, &clients, &mut operator) + .unwrap(); + assert_eq!( + operator["taskAuthority"]["issuer"], + "https://casework.local.example" + ); + assert_eq!(operator["sources"]["source"]["resource"], state.audience()); + assert_eq!(description.exchange_issuers.len(), 1); + let mut wrong = integrations.clone(); + wrong.sources.get_mut("source").unwrap().resource = Some("urn:other".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong + .sources + .get_mut("source") + .unwrap() + .client_assertion_audience = Some("https://other.example".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[1].scopes.push("records:get".into()); + assert!(wrong.validate(&clients, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[0] + .claims + .insert("registry_actor_kind".into(), json!("human")); + assert!(wrong.validate(&clients, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong + .sources + .get_mut("source") + .unwrap() + .client_assertion_key_ref = "secret:file/service-status-key".into(); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[0].scopes.push("records:patch".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + // Invalid binding input never installs a partially initialized session. + fs::write( + project.join("casework.yaml"), + serde_norway::to_string(&policy).unwrap(), + ) + .unwrap(); + fs::write(project.join("source.json"), serde_json::to_vec(&json!({ + "apiVersion":"registry.registrystack.org/casework-source-description/v1alpha1", + "kind":"BRegCaseworkSourceDescription","origin":"bregctl explain change-requests","authority":"none", + "sourceId":"source","sourceRevision":"sha256:source", + "request":{"requestEntity":"correction","requestRoute":"corrections","reviewMode":"staged", + "stages":[{"id":"review","approvals":1,"excludeSubmitter":true,"excludePreviousReviewers":false}], + "fields":[],"contractFingerprint":"sha256:contract","application":{"mode":"manual"}} + })).unwrap()).unwrap(); + let webhook = root.join("webhook-input"); + private::create(&webhook, b"synthetic-webhook-secret-at-least-32-bytes").unwrap(); + let mut staged_integrations = integrations.clone(); + staged_integrations + .secret_files + .insert("source-webhook".into(), webhook); + staged_integrations + .sources + .get_mut("source") + .unwrap() + .event_source = "urn:invalid:event".into(); + clients.integrations = Some(staged_integrations); + parent_directory(&project).unwrap(); + assert!(initialize(&state.root(), &state, &clients).is_err()); + assert!(!state.root().exists()); + clients + .integrations + .as_mut() + .unwrap() + .sources + .get_mut("source") + .unwrap() + .event_source = "urn:registrystack:registry:source:instance:local".into(); + initialize(&state.root(), &state, &clients).unwrap(); + assert!(state.root().join("operator.yaml").exists()); + let mut wrong = integrations; + wrong.sources.clear(); + assert!(wrong.validate(&clients, &policy).is_err()); +} diff --git a/crates/registry-thunderid-tooling/src/local.rs b/crates/registry-thunderid-tooling/src/local.rs index f230561240..64ade27214 100644 --- a/crates/registry-thunderid-tooling/src/local.rs +++ b/crates/registry-thunderid-tooling/src/local.rs @@ -57,6 +57,111 @@ fn derived_uuid(seed: &str) -> String { result } +fn add_scope( + resources: &mut BTreeMap, Resource>, + scope: &str, +) -> Result<(), ToolingError> { + let refuse = |reason| ToolingError::InvalidDescription { reason }; + let segments: Vec<_> = scope.split(':').collect(); + if segments.len() < 2 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + }) + { + return Err(refuse( + "local scopes must be exact colon-delimited upstream handles", + )); + } + let chain: Vec = segments[..segments.len() - 1] + .iter() + .map(|part| (*part).into()) + .collect(); + let action = segments[segments.len() - 1]; + for depth in 1..=chain.len() { + let prefix = chain[..depth].to_vec(); + resources.entry(prefix.clone()).or_insert_with(|| Resource { + name: chain[depth - 1].clone(), + handle: chain[depth - 1].clone(), + parent: (depth > 1).then(|| chain[depth - 2].clone()), + description: format!("local resource {}", prefix.join(":")), + actions: vec![], + }); + } + let leaf = resources.get_mut(&chain).expect("inserted resource chain"); + if !leaf + .actions + .iter() + .any(|existing| existing.handle == action) + { + leaf.actions.push(Action { + name: action.into(), + handle: action.into(), + description: format!("local permission {scope}"), + }); + } + Ok(()) +} + +/// Declare exact scope handles for another audience in the same native issuer. +/// This creates no role assignment and gives no client any additional permission. +pub fn declare_resource( + description: &mut IssuerDescription, + audience: &str, + scopes: &[String], +) -> Result { + let mut resources = BTreeMap::new(); + for scope in scopes { + add_scope(&mut resources, scope)?; + } + let server = if let Some(index) = description + .resource_servers + .iter() + .position(|server| server.identifier == audience) + { + &mut description.resource_servers[index] + } else { + description.resource_servers.push(ResourceServer { + id: derived_uuid(&format!("{}:resource:{audience}", description.session.id)), + name: "Configured local resource".into(), + identifier: audience.into(), + description: "Explicit local resource scope handles".into(), + resources: vec![], + }); + description + .resource_servers + .last_mut() + .expect("inserted resource server") + }; + for resource in resources.into_values() { + if let Some(existing) = server + .resources + .iter_mut() + .find(|entry| entry.handle == resource.handle) + { + if existing.parent != resource.parent { + return Err(ToolingError::InvalidDescription { + reason: "local resource handles must have one unambiguous parent", + }); + } + for action in resource.actions { + if !existing + .actions + .iter() + .any(|entry| entry.handle == action.handle) + { + existing.actions.push(action); + } + } + } else { + server.resources.push(resource); + } + } + Ok(server.id.clone()) +} + /// Render one audience and the exact declared scope trees. Colon-delimited /// handles must be directly representable by the pinned upstream grammar; /// no permission is renamed or approximated. Institutional grants use the @@ -116,46 +221,7 @@ pub fn typed_local_description( )); } for scope in &client.scopes { - let segments: Vec<_> = scope.split(':').collect(); - if segments.len() < 2 - || segments.iter().any(|segment| { - segment.is_empty() - || !segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - }) - { - return Err(refuse( - "local scopes must be exact colon-delimited upstream handles", - )); - } - let chain: Vec = segments[..segments.len() - 1] - .iter() - .map(|part| (*part).into()) - .collect(); - let action = segments[segments.len() - 1]; - for depth in 1..=chain.len() { - let prefix = chain[..depth].to_vec(); - resources.entry(prefix.clone()).or_insert_with(|| Resource { - name: chain[depth - 1].clone(), - handle: chain[depth - 1].clone(), - parent: (depth > 1).then(|| chain[depth - 2].clone()), - description: format!("local resource {}", prefix.join(":")), - actions: vec![], - }); - } - let leaf = resources.get_mut(&chain).expect("inserted resource chain"); - if !leaf - .actions - .iter() - .any(|existing| existing.handle == action) - { - leaf.actions.push(Action { - name: action.into(), - handle: action.into(), - description: format!("local permission {scope}"), - }); - } + add_scope(&mut resources, scope)?; } let native_id = agent_id(&session.id, &client.client_id); let role_id = derived_uuid(&format!("{}:role:{}", session.id, client.client_id)); @@ -329,4 +395,56 @@ mod tests { ); std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn declaring_an_exchange_target_adds_no_client_permission() { + let mut description = build(client()).unwrap(); + let permissions = description + .roles + .iter() + .map(|role| role.permissions.clone()) + .collect::>(); + let id = declare_resource( + &mut description, + "urn:synthetic:destination", + &["records:get".into()], + ) + .unwrap(); + assert_eq!( + declare_resource( + &mut description, + "urn:synthetic:destination", + &["records:patch".into()] + ) + .unwrap(), + id + ); + description.validate().unwrap(); + assert_eq!( + description + .roles + .iter() + .map(|role| role.permissions.clone()) + .collect::>(), + permissions + ); + assert_eq!( + description + .resource_servers + .iter() + .filter(|server| server.identifier == "urn:synthetic:destination") + .count(), + 1 + ); + assert_eq!( + description + .resource_servers + .iter() + .find(|server| server.id == id) + .unwrap() + .resources[0] + .actions + .len(), + 2 + ); + } } diff --git a/products/casework/DEV-SOURCES.md b/products/casework/DEV-SOURCES.md new file mode 100644 index 0000000000..e51f42f9af --- /dev/null +++ b/products/casework/DEV-SOURCES.md @@ -0,0 +1,138 @@ +# Source-backed development + +`caseworkctl dev` can run a source-backed project using one stock ThunderID +1.0.1 issuer. The operator explicitly connects a running BREG source; Casework +does not start that source or change its trust policy. Ordinary standalone +projects need no `integrations` block and have no task authority. + +First author the source description, queues, access profiles, and governed +`taskTemplates` in `casework.yaml`. Include each template subject field in the +source request's explicit `projection`, and grant the reader and approving human +profile disclosure of that field. The source reader must also disclose +`review_state` so Casework can observe the source-owned review stage. Import the source description for the current +compiled registry revision. Discover each task client's stable native +subject before authoring its `agent.subject`: + +```sh +caseworkctl dev identity task-agent +``` + +Set `agent.issuer` to the exact local issuer URL, such as +`http://127.0.0.1:8093`. The subject remains stable across local sessions; the +issuer URL qualifies it. Local human teaching clients remain explicit fixtures +in `dev-clients.yaml` and seed the directory through the normal API. + +Add an explicit `integrations` block beside `version`, `clients`, and +`directory` in that file. This example shows the operator-owned connection +shape; replace its resource, ports, source profile, event source and paths with +the source's actual configuration: + +```yaml +integrations: + resource: urn:example:local-review + sources: + professional-register: + baseUrl: http://127.0.0.1:8080 + readerProfile: casework-reader + tokenEndpoint: http://127.0.0.1:8093/oauth2/token + clientAssertionAudience: http://127.0.0.1:8093 + resource: urn:example:local-review + scopes: [records:get] + clientIdRef: secret:file/service-source-reader-id + clientAssertionKeyRef: secret:file/service-source-reader-key + webhookSecretRef: secret:file/source-webhook + eventSource: urn:registrystack:registry:professional-register:instance:local + secretFiles: + source-webhook: /absolute/owner-only/source-webhook-key + serviceClients: + - id: source-reader + scopes: [records:get] + - id: task-agent + scopes: [casework:grants:assert] + taskExchange: true + - id: breg-status + scopes: [casework:grants:status] + taskAuthority: + id: casework + issuer: https://casework.local.example + jwksPort: 8094 + statusClients: + breg-status: urn:example:local-review +``` + +`integrations.resource` is an explicit shared resource group. Casework forwards +the officer's current bearer for source disclosure and source actions. Configure +BREG to accept this exact issuer and resource, with independent human client +admission, actor category, required scopes and source profiles. Add those source +scopes to the corresponding human teaching client's `scopes`. A shared audience +alone grants no source access. In particular, BREG human profiles must exclude +the task agent and status clients. BREG task profiles still require their +configured authority, original issuer, client, purpose, operations and identity +bounds. The template's BREG bounds must match the selected task profile's full +effective permissions. A bootstrap token has no grant fields. + +Each `sources` entry must match a declared source. Its token endpoint, client +assertion audience and resource must match this session. These mismatches fail +before containers start. Both reader credential references must name the same +generated service client, whose effective resource and scopes exactly match the +source binding. Multiple sources may share that exact reader client. Service clients use `integrations.resource` unless +an explicit `resource` is supplied. Their exact scopes determine their ordinary +permissions. A task-exchange client receives only `casework:grants:assert` for +Casework; destination scopes and immutable bounds come from a real approved +governed template. Static grant attributes and caller-selected actor markers +are refused. Task clients cannot act as local humans. + +Only an explicit `taskAuthority` enables signing. Its `issuer` is a logical HTTPS +authority identifier, distinct from the loopback Casework API URL. Configure +BREG task profiles and status validation with this exact authority identifier; +the local issuer maps it to the explicit public JWKS listener. Its private key stays in the +owner-only retained session. The supervisor owns a public-key-only listener on +`0.0.0.0:` so the issuer container can verify actual Casework +assertions. It serves only `GET /oauth2/jwks`; other paths and methods fail, and +it stops with the session. The Casework API remains on loopback. Choose a +separate free port. The listener exposes public keys, never signing material. + +The session generates service credentials under +`.casework/dev/credentials//assertion-key.jwk` and corresponding runtime +secret references `secret:file/service--id` and +`secret:file/service--key`. Imported secret files must be absolute, +owner-only, ordinary files with `source-` prefixed destination names. Credentials +are copied at first initialization and retained across restart. Source +descriptions, the project and clients file are pinned together; changed +authoring cannot silently change a populated session. + +Start the session with `caseworkctl dev`. Its report names the issuer, exact +resource and operator configuration. Configure the source's own issuer trust +and generated source-reader credential, then ensure the source is running. +Casework reconciles active source requests on startup and every minute. Use +`caseworkctl dev token staff` to write a fresh owner-only human fixture header. +Approve a displayed governed template through the Casework UI or API. The +approval request contains only its template ID and version, with current item +`If-Match` and an idempotency key. + +To acquire the approved UUID, create an owner-only connection file: + +```yaml +version: 1 +caseworkUrl: http://127.0.0.1:8092 +tokenEndpoint: http://127.0.0.1:8093/oauth2/token +clientAssertionAudience: http://127.0.0.1:8093 +bootstrapResource: urn:example:local-review +clients: + task-agent: + assertionKeyFile: /absolute/project/.casework/dev/credentials/task-agent/assertion-key.jwk + resource: urn:example:local-review + scopes: [records:get] +``` + +```sh +caseworkctl dev grant task-agent --grant APPROVED_UUID --connection /absolute/connection.yaml ./casework +``` + +The command writes a grant-specific header with mode `0600` and reports its +path and immutable deadline. It obtains a fresh Casework assertion and performs +standard token exchange; it cannot approve a task or select subjects, purposes +or bounds. Unknown, expired or revoked approvals fail. Restart retains the +issuer keys, directory, database and grant deadline. `dev stop` stops owned +services; `dev stop --remove` explicitly removes that session's database and +private state according to the normal dev lifecycle. diff --git a/products/casework/README.md b/products/casework/README.md index 9ba1fade3e..f8dbbd698e 100644 --- a/products/casework/README.md +++ b/products/casework/README.md @@ -327,9 +327,9 @@ Casework. Activating a new package does not rewrite running clock occurrences; each keeps its pinned clock policy and calculation. Holiday changes use the Administrator preview-and-apply flow described above. -The source-backed starter cannot run under `caseworkctl dev`: every declared -source needs a running source system and a reader credential that the local -supervisor cannot create. Follow +The source-backed starter can use the explicit local configuration in +[Source-backed development](DEV-SOURCES.md). Every declared source still needs +a running source system with its own access profiles. For a deployment, follow [Deploy Registry Casework](../../docs/site/src/content/docs/operate/casework.mdx) to install the package, runtime configuration, and credentials. The deployment runtime applies migrations and serves the package through the `casework` From ff93208d40590d3e8fddf2dafbb09c7c011a9251 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:43:44 +0700 Subject: [PATCH 062/120] test(casework): prove local task grants through stock issuer Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 68 +-- crates/registry-casework/src/task_grants.rs | 6 + .../src/task_grants/local_session_tests.rs | 571 ++++++++++++++++++ .../src/task_grants/native_exchange_tests.rs | 3 +- .../src/task_grants/native_resource.rs | 30 +- .../contracts/security-invariant-matrix.yaml | 1 + .../contracts/security-test-traceability.yaml | 1 + 7 files changed, 640 insertions(+), 40 deletions(-) create mode 100644 crates/registry-casework/src/task_grants/local_session_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0823ca1c52..a5b0788f9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,11 +142,6 @@ jobs: - name: Test advisory baseline exposure policy run: python3 -m unittest release/scripts/test_check_advisory_baselines.py - - name: Test Mint demonstration support scripts - run: | - python3 -m unittest discover \ - --start-directory crates/registry-mint/demo/support - secrets: name: Secret scan runs-on: ubuntu-24.04 @@ -582,6 +577,8 @@ jobs: run: products/relay-v2/scripts/check-authoring-schema.sh - name: Relay V2 coequal HTTP journeys + env: + RELAY_V2_STOCK_ISSUER: "1" run: products/relay-v2/scripts/test-http.sh - name: Relay V2 tutorial reader gate @@ -780,6 +777,22 @@ jobs: BREG_POSTGRES_LANE: ${{ matrix.lane }} run: products/breg/scripts/test-postgres.sh --lane "$BREG_POSTGRES_LANE" + - name: Casework task approval through stock issuer and BReg + if: matrix.lane == 'postgres' + shell: bash + run: | + set -euo pipefail + CASEWORK_ASSIGNMENT_TEST_DATABASE_URL="${BREG_TEST_DATABASE_URL}" \ + cargo test --locked --profile ci -p registry-casework \ + --features postgres-test --lib \ + approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes \ + -- --ignored + cargo build --locked --profile ci -p registry-casework -p registry-caseworkctl + cargo test --locked --profile ci -p registry-casework \ + --features postgres-test --lib \ + source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer \ + -- --ignored + - name: Base Registry Engine PostgreSQL TLS proof if: matrix.lane == 'contracts' run: products/breg/scripts/test-postgres-tls.sh @@ -1108,7 +1121,7 @@ jobs: CARGO_TARGET_DIR="target/evidence-tutorial-source" \ cargo build --locked --profile ci \ -p registry-evidence -p registry-evidencectl \ - -p registry-evidence-oid4vci -p registry-mint + -p registry-evidence-oid4vci CARGO_TARGET_DIR="target/evidence-tutorial-source" \ cargo test --locked --profile ci \ -p registry-evidence-oid4vci --test inji_interoperability --no-run @@ -1167,7 +1180,6 @@ jobs: shell: bash env: EVIDENCE_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/evidence - MINT_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/mint run: | set -euo pipefail CARGO_TARGET_DIR="target/evidence-tutorial-source" \ @@ -1175,23 +1187,17 @@ jobs: -p registry-evidencectl --test dev_lifecycle -- \ --ignored --test-threads=1 - - name: Execute the Evidence tutorials in a clean container + - name: Execute the Evidence tutorials with the stock issuer shell: bash + env: + EVIDENCE_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/evidence + EVIDENCECTL_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/evidencectl + EVIDENCE_OID4VCI_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/evidence-oid4vci + EVIDENCE_OID4VCI_INTEROP_TEST_BIN: ${{ github.workspace }}/target/evidence-tutorial-source/ci/inji-oid4vci-interop-test run: | set -euo pipefail - # The image is the repository's pinned release-builder digest, used - # here only as a clean Debian userland: the gate exercises a shell - # and coreutils, and the toolset binaries are mounted in prebuilt. - docker run --rm \ - --mount "type=bind,src=${PWD},dst=/work,readonly" \ - --env EVIDENCE_BIN=/work/target/evidence-tutorial-source/ci/evidence \ - --env EVIDENCECTL_BIN=/work/target/evidence-tutorial-source/ci/evidencectl \ - --env EVIDENCE_OID4VCI_BIN=/work/target/evidence-tutorial-source/ci/evidence-oid4vci \ - --env EVIDENCE_OID4VCI_INTEROP_TEST_BIN=/work/target/evidence-tutorial-source/ci/inji-oid4vci-interop-test \ - --env MINT_BIN=/work/target/evidence-tutorial-source/ci/mint \ - --env "REGISTRY_CLIENT_PY_WHEEL=${TUTORIAL_CLIENT_WHEEL}" \ - rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 \ - bash /work/docs/site/scripts/check-evidence-tutorials.sh + REGISTRY_CLIENT_PY_WHEEL="${GITHUB_WORKSPACE}/${TUTORIAL_CLIENT_WHEEL#/work/}" \ + bash docs/site/scripts/check-evidence-tutorials.sh breg-tutorial: name: Base Registry Engine tutorial from source @@ -1242,7 +1248,7 @@ jobs: CARGO_TARGET_DIR="target/breg-tutorial-source" \ cargo build --locked --profile ci \ -p registry-breg --features registry-breg/runtime \ - -p registry-bregctl -p registry-mint --bins + -p registry-bregctl --bins - name: Verify native retained development lifecycle shell: bash @@ -1279,7 +1285,6 @@ jobs: env: BREG_BIN: ${{ github.workspace }}/target/breg-tutorial-source/ci/breg BREGCTL_BIN: ${{ github.workspace }}/target/breg-tutorial-source/ci/bregctl - MINT_BIN: ${{ github.workspace }}/target/breg-tutorial-source/ci/mint run: | set -euo pipefail # This replay does not run in a clean read-only container the way the @@ -1330,13 +1335,9 @@ jobs: shell: bash run: | set -euo pipefail - # The two-product page runs a Base Registry Engine beside Casework, - # so its binaries are part of the toolset the replay serves. CARGO_TARGET_DIR="target/casework-tutorial-source" \ cargo build --locked --profile ci \ - -p registry-casework -p registry-caseworkctl \ - -p registry-breg --features registry-breg/runtime \ - -p registry-bregctl --bins + -p registry-casework -p registry-caseworkctl --bins - name: Verify the retained local development lifecycle shell: bash @@ -1353,13 +1354,11 @@ jobs: env: CASEWORK_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/casework CASEWORKCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/caseworkctl - BREG_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/breg - BREGCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/bregctl run: | set -euo pipefail - # The tutorials pass no port flags, and this runner listens on none - # of the defaults, so the replay runs on the ports a reader gets. - # Each runtime the pages start runs PostgreSQL in a disposable + # The tutorial passes no port flags, and this runner listens on none + # of the three defaults, so the replay runs on the ports a reader + # gets. The runtime the page starts runs PostgreSQL in a disposable # container of its own, which is why the replay stays on the Docker # runner rather than inside a clean read-only container. bash docs/site/scripts/check-casework-tutorial.sh @@ -1461,9 +1460,6 @@ jobs: cache: npm cache-dependency-path: docs/site/package-lock.json - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.95.0 --profile minimal - - name: Install docs shell dependency run: | sudo apt-get update diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 4a7ddaf3ac..5ae4a4afef 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -813,3 +813,9 @@ mod http_tests; #[cfg(all(test, feature = "postgres-test"))] mod native_exchange_tests; + +#[cfg(all(test, feature = "postgres-test"))] +mod local_session_tests; + +#[cfg(all(test, feature = "postgres-test"))] +mod native_resource; diff --git a/crates/registry-casework/src/task_grants/local_session_tests.rs b/crates/registry-casework/src/task_grants/local_session_tests.rs new file mode 100644 index 0000000000..7a299451ba --- /dev/null +++ b/crates/registry-casework/src/task_grants/local_session_tests.rs @@ -0,0 +1,571 @@ +//! Installed Casework dev session, stock issuer and actual source-backed BREG HTTP/PostgreSQL. +//! Build casework/caseworkctl and set disposable BREG_TEST_DATABASE_URL before opting in. +use super::native_resource as resource; +use axum::http::{Method, StatusCode}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig}; +use serde_json::{json, Value}; +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, + sync::Arc, + time::Duration, +}; +const AUDIENCE: &str = "urn:breg:task-test"; +const AUTHORITY: &str = "https://casework.local.example"; + +struct LocalSession { + project: PathBuf, + binary: PathBuf, + ports: [u16; 4], +} +impl LocalSession { + async fn ctl(&self, args: Vec) -> std::process::Output { + let binary = self.binary.clone(); + tokio::task::spawn_blocking(move || { + Command::new(binary) + .args(["--format", "json"]) + .args(args) + .output() + .unwrap() + }) + .await + .unwrap() + } + async fn success(&self, args: Vec) -> Value { + let result = self.ctl(args).await; + assert!( + result.status.success(), + "CLI refusal: stdout={} stderr={}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + serde_json::from_slice(&result.stdout).unwrap() + } + async fn start(&self) -> Value { + self.success(vec![ + "dev".into(), + self.project.display().to_string(), + "--casework-port".into(), + self.ports[0].to_string(), + "--issuer-port".into(), + self.ports[1].to_string(), + "--database-port".into(), + self.ports[2].to_string(), + "--casework-bin".into(), + self.binary.with_file_name("casework").display().to_string(), + ]) + .await + } + async fn stop(&self) { + self.success(vec![ + "dev".into(), + "stop".into(), + self.project.display().to_string(), + ]) + .await; + } + async fn token(&self, id: &str) -> String { + let report = self + .success(vec![ + "dev".into(), + "token".into(), + id.into(), + self.project.display().to_string(), + ]) + .await; + read_header(Path::new(report["headerFile"].as_str().unwrap())) + } + fn root(&self) -> PathBuf { + self.project.join(".casework/dev") + } + fn issuer(&self) -> String { + format!("http://127.0.0.1:{}", self.ports[1]) + } + fn url(&self) -> String { + format!("http://127.0.0.1:{}", self.ports[0]) + } +} +impl Drop for LocalSession { + fn drop(&mut self) { + let output = Command::new(&self.binary) + .args(["--format", "json", "dev", "stop"]) + .arg(&self.project) + .arg("--remove") + .output() + .unwrap(); + if !std::thread::panicking() { + assert!(output.status.success(), "owned dev cleanup failed"); + } + } +} +fn read_header(path: &Path) -> String { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + fs::read_to_string(path) + .unwrap() + .trim() + .strip_prefix("Authorization: Bearer ") + .unwrap() + .to_owned() +} +fn payload(token: &str) -> Value { + serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(token.split('.').nth(1).unwrap()) + .unwrap(), + ) + .unwrap() +} +fn private(path: &Path, value: &[u8]) { + fs::write(path, value).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); +} +async fn http( + method: &str, + url: &str, + token: &str, + human: bool, + revision: Option, + body: Option, +) -> (StatusCode, Value) { + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let mut request = client + .request(method.parse().unwrap(), url) + .bearer_auth(token); + if human { + request = request + .header("registry-casework-profile", "staff") + .header("registry-source-profile", "reviewer"); + } + if let Some(revision) = revision { + request = request + .header("if-match", format!("\"{revision}\"")) + .header("idempotency-key", uuid::Uuid::new_v4().to_string()); + } + if let Some(body) = body { + request = request.json(&body); + } + let response = request.send().await.unwrap(); + let status = response.status(); + (status, response.json().await.unwrap()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires Docker, built casework/caseworkctl and disposable BREG_TEST_DATABASE_URL"] +async fn source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer() { + let workspace = tempfile::tempdir().unwrap(); + let held = (0..4) + .map(|_| std::net::TcpListener::bind("127.0.0.1:0").unwrap()) + .collect::>(); + let ports = held + .iter() + .map(|listener| listener.local_addr().unwrap().port()) + .collect::>() + .try_into() + .unwrap(); + drop(held); + let binary = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("caseworkctl"); + assert!( + binary.is_file() && binary.with_file_name("casework").is_file(), + "build casework and caseworkctl first" + ); + let session = LocalSession { + project: workspace.path().join("casework"), + binary, + ports, + }; + fs::create_dir(&session.project).unwrap(); + fs::create_dir(session.project.join("sources")).unwrap(); + let source_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let source_url = format!("http://{}", source_listener.local_addr().unwrap()); + let mut project: Value = + serde_json::from_str(&resource::PROJECT.replace("\"placement\"", "\"record\"")).unwrap(); + project["accessProfiles"][0]["actorKind"] = json!("service"); + project["accessProfiles"][0]["requesterClients"] = json!(["seed-client"]); + project["accessProfiles"][0]["requiredScopes"] = json!(["records:get"]); + project["accessProfiles"][1]["taskGrant"]["sourceIssuer"] = json!(AUTHORITY); + project["accessProfiles"][1]["permissions"][0]["operations"] = + json!(["create", "get", "patch", "submit_request"]); + project["accessProfiles"][1]["permissions"][0] + .as_object_mut() + .unwrap() + .remove("revisionAccess"); + let mut source_creator = project["accessProfiles"][1].clone(); + source_creator["id"] = json!("source-creator"); + source_creator["default"] = json!(false); + source_creator["actorKind"] = json!("service"); + source_creator["requesterClients"] = json!(["seed-client"]); + source_creator.as_object_mut().unwrap().remove("taskGrant"); + project["accessProfiles"] + .as_array_mut() + .unwrap() + .push(source_creator); + project["accessProfiles"][2]["permissions"][0]["readableRequestFields"] = + json!(["reason", "review_state"]); + project["accessProfiles"][2]["actorKind"] = json!("human"); + project["accessProfiles"][2]["requesterClients"] = json!(["staff"]); + project["accessProfiles"][2]["requiredScopes"] = json!(["records:get"]); + let mut reader = project["accessProfiles"][2].clone(); + reader["id"] = json!("reader"); + reader["actorKind"] = json!("service"); + reader["requesterClients"] = json!(["source-reader"]); + reader["permissions"][0]["operations"] = json!(["get", "list"]); + reader["permissions"][0] + .as_object_mut() + .unwrap() + .remove("reviewStages"); + project["accessProfiles"] + .as_array_mut() + .unwrap() + .push(reader); + let registry = Arc::new( + registry_breg::compile_project( + ®istry_breg::parse_project_json(&serde_json::to_vec(&project).unwrap()).unwrap(), + &[], + registry_breg::CompileProfile::Authoring, + ) + .unwrap(), + ); + let entity = ®istry.entities()["correction-request"]; + let contract = entity.change_request.as_ref().unwrap(); + let schema: Value = serde_json::from_slice( + ®istry + .artifacts() + .get("generated/schemas/correction-request.schema.json") + .unwrap() + .bytes, + ) + .unwrap(); + let description = json!({"apiVersion":"registry.registrystack.org/casework-source-description/v1alpha1","kind":"BRegCaseworkSourceDescription","origin":"bregctl explain change-requests","authority":"none","sourceId":"source","sourceRevision":registry.revision(),"request":{"requestEntity":"correction-request","requestRoute":"correction-requests","reviewMode":"staged","stages":[{"id":"review","approvals":1,"excludeSubmitter":true,"excludePreviousReviewers":false}],"application":{"mode":"manual"},"contractFingerprint":contract.contract_fingerprint,"fields":entity.stored_fields.iter().map(|field|json!({"field":field.logical.id,"apiName":field.logical.api_name,"schema":schema["properties"][&field.logical.api_name]})).collect::>()}}); + fs::write( + session.project.join("sources/source.json"), + serde_json::to_vec(&description).unwrap(), + ) + .unwrap(); + let identity = session + .success(vec!["dev".into(), "identity".into(), "task-agent".into()]) + .await; + let policy = json!({"apiVersion":"registry.registrystack.org/casework/v1alpha1","kind":"CaseworkProject","casework":{"id":"source-local","version":"1"},"accessProfiles":[{"id":"supervisor","principalClaim":"sub","requiredScopes":["casework:supervisor"],"role":"supervisor"},{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"},{"id":"administrator","principalClaim":"sub","requiredScopes":["casework:admin"],"role":"administrator"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"breg","description":"sources/source.json","requests":[{"entity":"correction-request","queue":"review","projection":["tenant"]}]}],"taskTemplates":[{"id":"draft","version":"1","label":"Prepare correction","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["correction-request"],"itemStates":["claimed"],"agent":{"issuer":session.issuer(),"subject":identity["subject"]},"client":"task-agent","resource":AUDIENCE,"scopes":["records:get"],"purpose":"review","bounds":{"type":"breg","permissions":[{"collection":"correction-requests","operations":["create","get","patch","submit_request"]}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900}]}); + fs::write( + session.project.join("casework.yaml"), + serde_norway::to_string(&policy).unwrap(), + ) + .unwrap(); + let webhook = workspace.path().join("webhook"); + private(&webhook, b"synthetic-source-webhook-key-32-bytes"); + let clients = json!({"version":1,"clients":[{"id":"supervisor","accessProfile":"supervisor","scopes":["casework:supervisor"],"claims":{"registry_actor_kind":"human"}},{"id":"administrator","accessProfile":"administrator","scopes":["casework:admin"],"claims":{"registry_actor_kind":"human"}},{"id":"staff","accessProfile":"staff","scopes":["casework:staff","records:get"],"claims":{"registry_actor_kind":"human","tenant_claim":"tenant-a","registry_purpose":"review"}}],"directory":[{"team":"team","queue":"review","staff":["staff"],"supervisors":["supervisor"]}],"integrations":{"resource":AUDIENCE,"sources":{"source":{"baseUrl":source_url,"readerProfile":"reader","tokenEndpoint":format!("{}/oauth2/token",session.issuer()),"clientAssertionAudience":session.issuer(),"resource":AUDIENCE,"scopes":["records:get"],"clientIdRef":"secret:file/service-source-reader-id","clientAssertionKeyRef":"secret:file/service-source-reader-key","webhookSecretRef":"secret:file/source-webhook","eventSource":"urn:registrystack:registry:task-authority-http:instance:task-instance"}},"secretFiles":{"source-webhook":webhook},"serviceClients":[{"id":"source-reader","scopes":["records:get"],"claims":{"tenant_claim":"tenant-a","registry_purpose":"review"}},{"id":"seed-client","scopes":["records:get"],"claims":{"tenant_claim":"tenant-a","registry_purpose":"review"}},{"id":"task-agent","scopes":["casework:grants:assert"],"taskExchange":true},{"id":"status-client","scopes":["casework:grants:status"]}],"taskAuthority":{"id":"casework","issuer":AUTHORITY,"jwksPort":session.ports[3],"statusClients":{"status-client":AUDIENCE}}}}); + fs::write( + session.project.join("dev-clients.yaml"), + serde_norway::to_string(&clients).unwrap(), + ) + .unwrap(); + session.start().await; + let jwks: Value = + serde_json::from_slice(&fs::read(session.root().join("secrets/issuer-jwks")).unwrap()) + .unwrap(); + let status_key = registry_platform_crypto::PrivateJwk::parse( + &fs::read_to_string( + session + .root() + .join("credentials/status-client/assertion-key.jwk"), + ) + .unwrap(), + ) + .unwrap(); + let provider = PrivateKeyJwt::new( + PrivateKeyJwtConfig::new( + format!("{}/oauth2/token", session.issuer()) + .parse() + .unwrap(), + "status-client", + status_key, + ) + .with_audience(session.issuer()) + .with_resource(AUDIENCE) + .with_scopes(["casework:grants:status"]), + ) + .unwrap(); + let checker = Arc::new( + registry_breg::task_grant::TaskGrantStatusClient::new( + "casework".into(), + AUTHORITY.into(), + AUDIENCE.into(), + session.url().parse().unwrap(), + Arc::new(provider), + None, + ) + .unwrap(), + ); + let db = resource::TestDatabase::create(9).await; + let installed = resource::install(&db, ®istry).await; + let app = resource::app_with_clients( + &db, + registry, + installed, + &session.issuer(), + jwks.clone(), + checker, + vec![ + "task-agent".into(), + "seed-client".into(), + "source-reader".into(), + "staff".into(), + "status-client".into(), + ], + ); + let served = app.clone(); + let server = tokio::spawn(async move { axum::serve(source_listener, served).await.unwrap() }); + let seed = session.token("seed-client").await; + assert_eq!(payload(&seed)["tenant_claim"], "tenant-a"); + assert_eq!(payload(&seed)["registry_actor_kind"], "service"); + assert_eq!(payload(&seed)["aud"], AUDIENCE); + let old = resource::create( + &app, + "/v1/records/sites?accessProfile=steward", + &seed, + "local-old", + json!({"tenant":"tenant-a","name":"Old"}), + ) + .await; + let next = resource::create( + &app, + "/v1/records/sites?accessProfile=steward", + &seed, + "local-next", + json!({"tenant":"tenant-a","name":"Next"}), + ) + .await; + let placement = resource::create( + &app, + "/v1/records/placements?accessProfile=steward", + &seed, + "local-placement", + json!({"tenant":"tenant-a","site":resource::id(&old)}), + ) + .await; + let request_data = json!({"tenant":"tenant-a","record":resource::id(&placement),"proposedSite":resource::id(&next),"reason":"Synthetic correction"}); + let draft = resource::create( + &app, + "/v1/records/correction-requests?accessProfile=source-creator", + &seed, + "local-source", + request_data.clone(), + ) + .await; + let current = resource::get(&app, &resource::id(&draft), "source-creator", &seed).await; + let action = current.body["data"]["request"]["actions"] + .as_array() + .unwrap() + .iter() + .find(|action| action["operation"] == "submit_request") + .unwrap(); + let submitted = resource::send( + &app, + Method::POST, + action["href"].as_str().unwrap(), + &seed, + Some("local-submit"), + Some(action["ifMatch"].as_str().unwrap()), + json!({}), + ) + .await; + assert_eq!(submitted.status, StatusCode::OK); + use registry_casework_core::SourceAdapter; + let runtime = crate::RuntimeConfig::load(session.root().join("operator.yaml")).unwrap(); + let secrets = crate::secret_resolver(&runtime).unwrap(); + let authored = + registry_casework_core::CaseworkProject::load(session.project.join("casework.yaml")) + .unwrap(); + let adapter = runtime.sources["source"] + .build_adapter(&authored.sources[0], &session.project, &secrets) + .unwrap(); + let discovered = adapter + .discover_active(None, 25) + .await + .expect("actual source reader can discover active requests"); + assert_eq!(discovered.subjects.len(), 1); + adapter + .read_authoritative(&discovered.subjects[0]) + .await + .expect("actual source reader can observe its request"); + session.stop().await; + session.start().await; // Immediate source reconciliation uses the retained issuer and real submitted source. + let human = session.token("staff").await; + let mut items = Value::Null; + for _ in 0..100 { + let (status, body) = http( + "GET", + &format!( + "{}/v1/work-items?view=my_teams&queue=review&limit=25", + session.url() + ), + &human, + true, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + if body["items"] + .as_array() + .is_some_and(|items| !items.is_empty()) + { + items = body; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + items["items"].is_array(), + "source item should reconcile into Casework" + ); + let item = &items["items"][0]; + let id = item["itemId"] + .as_str() + .or_else(|| item["id"].as_str()) + .unwrap(); + let (status, claimed) = http( + "POST", + &format!("{}/v1/work-items/{id}/claim", session.url()), + &human, + true, + Some(item["revision"].as_i64().unwrap()), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let revision = claimed["item"]["revision"].as_i64().unwrap(); + let (status, preview) = http( + "GET", + &format!("{}/v1/work-items/{id}/task-templates", session.url()), + &human, + true, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + preview["templates"][0]["subjects"]["tenant_claim"], + "tenant-a" + ); + let (status, grant) = http( + "POST", + &format!("{}/v1/work-items/{id}/task-grants", session.url()), + &human, + true, + Some(revision), + Some(json!({"templateId":"draft","templateVersion":"1"})), + ) + .await; + assert_eq!(status, StatusCode::OK); + let grant_id = grant["id"].as_str().unwrap(); + let bootstrap = session.token("task-agent").await; + assert!(payload(&bootstrap).get("registry_grant_id").is_none()); + for client in ["task-agent", "status-client"] { + let token = session.token(client).await; + let denied = resource::send( + &app, + Method::GET, + &format!( + "/v1/records/correction-requests/{}?accessProfile=reviewer", + resource::id(&draft) + ), + &token, + None, + None, + Value::Null, + ) + .await; + assert_eq!(denied.status, StatusCode::NOT_FOUND); + } + let connection = workspace.path().join("connection.yaml"); + private(&connection,serde_norway::to_string(&json!({"version":1,"caseworkUrl":session.url(),"tokenEndpoint":format!("{}/oauth2/token",session.issuer()),"clientAssertionAudience":session.issuer(),"bootstrapResource":AUDIENCE,"clients":{"task-agent":{"assertionKeyFile":session.root().join("credentials/task-agent/assertion-key.jwk"),"resource":AUDIENCE,"scopes":["records:get"]}}})).unwrap().as_bytes()); + let args = vec![ + "dev".into(), + "grant".into(), + "task-agent".into(), + "--grant".into(), + grant_id.into(), + "--connection".into(), + connection.display().to_string(), + session.project.display().to_string(), + ]; + let issued = session.success(args.clone()).await; + let header = PathBuf::from(issued["headerFile"].as_str().unwrap()); + let token = read_header(&header); + let claims = payload(&token); + assert_eq!(claims["registry_grant_exp"], grant["expiresAt"]); + assert_eq!(claims["identity"], json!({"tenant_claim":"tenant-a"})); + assert_eq!(claims["registry_grant_source_issuer"], AUTHORITY); + let task_draft = resource::create( + &app, + "/v1/records/correction-requests?accessProfile=submitter", + &token, + "local-task-draft", + request_data.clone(), + ) + .await; + assert_eq!(task_draft.status, StatusCode::CREATED); + session.stop().await; + session.start().await; + assert_eq!( + serde_json::from_slice::( + &fs::read(session.root().join("secrets/issuer-jwks")).unwrap() + ) + .unwrap(), + jwks + ); + let repeated = session.success(args.clone()).await; + assert_eq!( + payload(&read_header(Path::new( + repeated["headerFile"].as_str().unwrap() + )))["registry_grant_exp"], + claims["registry_grant_exp"] + ); + let human = session.token("staff").await; + let (status, _) = http( + "POST", + &format!( + "{}/v1/work-items/{id}/task-grants/{grant_id}/revoke", + session.url() + ), + &human, + true, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + let before = resource::counts(&db).await; + let refused = resource::send( + &app, + Method::POST, + "/v1/records/correction-requests?accessProfile=submitter", + &token, + Some("local-revoked"), + None, + json!({"data":request_data}), + ) + .await; + assert_eq!(refused.status, StatusCode::PRECONDITION_FAILED); + assert_eq!(resource::counts(&db).await, before); + assert!(!session.ctl(args).await.status.success()); + server.abort(); + db.cleanup().await; +} diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 31242039cf..732c4226dd 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -3,6 +3,7 @@ //! Set the two disposable database variables named by the ignore reason, then run //! `cargo test --locked -p registry-casework --features postgres-test --lib //! approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes -- --ignored`. +use super::native_resource as resource; use super::*; use async_trait::async_trait; use axum::{ @@ -28,8 +29,6 @@ use wiremock::{ matchers::{method, path}, Mock, MockServer, ResponseTemplate, }; -#[path = "native_resource.rs"] -mod resource; const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; const BREG_RESOURCE: &str = "urn:breg:task-test"; const EVIDENCE_RESOURCE: &str = "urn:registry:evidence:fixture"; diff --git a/crates/registry-casework/src/task_grants/native_resource.rs b/crates/registry-casework/src/task_grants/native_resource.rs index e41ecc80ad..e38580c339 100644 --- a/crates/registry-casework/src/task_grants/native_resource.rs +++ b/crates/registry-casework/src/task_grants/native_resource.rs @@ -190,6 +190,27 @@ pub(super) fn app( issuer: &str, jwks: serde_json::Value, status: Arc, +) -> Router { + app_with_clients( + db, + registry, + identity, + issuer, + jwks, + status, + vec!["task-agent".into(), "seed-client".into()], + ) +} +/// The local-session proof admits its explicitly rendered service and human clients. +#[allow(clippy::too_many_arguments)] +pub(super) fn app_with_clients( + db: &TestDatabase, + registry: Arc, + identity: ExpectedRegistryIdentity, + issuer: &str, + jwks: Value, + status: Arc, + clients: Vec, ) -> Router { let pool = db.runtime_config.build_pool().unwrap(); let lock = RegistryLockKey::derive(PACKAGE).unwrap(); @@ -228,7 +249,7 @@ pub(super) fn app( vec!["at+jwt".into()], ) .with_scope_claim("scope") - .with_allowed_clients(vec!["task-agent".into(), "seed-client".into()]); + .with_allowed_clients(clients); let auth = RegistryAuthenticator::new( ®istry, verifier, @@ -320,7 +341,12 @@ pub(super) async fn create( json!({"data":data}), ) .await; - assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); + assert_eq!( + r.status, + StatusCode::CREATED, + "fixture operation {key}: {}", + r.body + ); r } pub(super) fn id(response: &Response) -> String { diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index a8028ce794..134fb6c6b6 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -23,3 +23,4 @@ invariants: - {id: CASEWORK-SEC-15, invariants: [11, 18], state: enforced, targetWave: mvp, threat: A stale timer or recompute fires an effect for completed work or repeats an effect after restart., enforcementPoint: pinned clock calculation and generation with transactional source revision and occurrence fences, refusal: Cancel terminal or superseded clocks and reject stale claims or recompute previews before recording reminders or local reassignment., negativeId: CASEWORK-NEG-15, negativeTest: {path: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}} - {id: CASEWORK-SEC-16, invariants: [1, 3, 17, 18], state: enforced, targetWave: mvp, threat: A requester or former team member records an outcome or a policy change alters an existing hosted item., enforcementPoint: pinned hosted kind policy with separate Requester and human deciding profiles and transactional membership checks, refusal: Refuse unauthorized decisions and isolate requester feeds while cancellation and decision commit only one terminal result., negativeId: CASEWORK-NEG-16, negativeTest: {path: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}} - {id: CASEWORK-SEC-17, invariants: [6, 9, 17, 18], state: enforced, targetWave: mvp, threat: Retained payloads or cached replay responses disclose erased source or hosted data or allow rediscovery to restore it., enforcementPoint: explicit source erasure and hosted retention with transactional tombstones and bounded cursor cleanup, refusal: Preserve live attempt recovery before erasure and refuse expired replay without returning retained payloads or rehydrating erased source items., negativeId: CASEWORK-NEG-17, negativeTest: {path: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}} + - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human and restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval and standard token exchange, refusal: Refuse bootstrap and status clients at the source human profile and refuse revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index c086142ae0..85f2f05cde 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -19,3 +19,4 @@ entries: - {id: CASEWORK-SEC-15, tests: [{file: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}]} - {id: CASEWORK-SEC-16, tests: [{file: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: cancel_and_decision_race_produces_one_stable_terminal_event}, {file: crates/registry-casework/tests/hosted_standalone.rs, name: ten_items_two_create_retries_and_one_terminal_result_without_breg}]} - {id: CASEWORK-SEC-17, tests: [{file: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: terminal_cursors_and_independent_retention_are_enforced_and_erased}]} + - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} From 4f6732ab85154ed6e3096ef67ad51d8a18e3db61 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:45:16 +0700 Subject: [PATCH 063/120] docs(cli): review final contextual authorization commands Signed-off-by: Jeremi Joslin --- docs/site/src/data/cli-reference.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index bc53e08af1..02cf4a188e 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-13 reviewed_source_version: "0.30.0" -reviewed_catalog_sha256: 18ab8f98f216a3fe6b17cde0344767f178d6ef8b61d47d5f6efb138264bf2f1c -reviewed_content_sha256: 7c7ed6e4fc8037ba6a751dcfcf14e8190972271fc36db18d1d9687098fe72e09 +reviewed_catalog_sha256: 8ceb8124fa5820892edc57a41ad31bfa9b820645828f78f94f23433a284458f2 +reviewed_content_sha256: 8ab656d13d7740f84bb510a70d9e55beda1a1491d268cd369fae7804fb2698b2 From 8477644e17aa871c73baeb187d913bb4846b03ed Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:45:17 +0700 Subject: [PATCH 064/120] style(evidence): format retired option regression Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/tests/doctor.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs index 72fbbac1aa..02a16f8575 100644 --- a/crates/registry-evidencectl/tests/doctor.rs +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -238,8 +238,7 @@ fn retired_mint_compatibility_option_is_not_accepted() { assert_eq!(output.status.code(), Some(2)); let stderr = stderr_of(&output); assert!( - stderr.contains("error[evidencectl.usage]") - && stderr.contains("unsupported arguments"), + stderr.contains("error[evidencectl.usage]") && stderr.contains("unsupported arguments"), "retired Mint compatibility option was not refused by the parser: {stderr}" ); assert!( From 27d0245fefb9ca3a2687293af94fc2e4c00f2b84 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:58:30 +0700 Subject: [PATCH 065/120] fix(breg): guard quickstart state lifecycle Signed-off-by: Jeremi Joslin --- products/breg/quickstart/run.sh | 28 +++++++++++++++---- .../breg/quickstart/support/quickstart.py | 27 ++++++++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/products/breg/quickstart/run.sh b/products/breg/quickstart/run.sh index 120c705877..df2a4f62a3 100755 --- a/products/breg/quickstart/run.sh +++ b/products/breg/quickstart/run.sh @@ -6,6 +6,10 @@ run_dir="$quickstart_dir/.run" support="$quickstart_dir/support/quickstart.py" spatial=false; smoke=false; installed=false for arg in "$@"; do case "$arg" in --spatial) spatial=true;; --smoke) smoke=true;; --installed) installed=true;; *) echo 'usage: products/breg/quickstart/run.sh [--installed] [--spatial] [--smoke]' >&2; exit 2;; esac; done +if [[ -L "$run_dir" || -e "$run_dir" ]]; then + printf '%s\n' "quickstart state path already exists: $run_dir. Stop its owned dev session before removing it." >&2 + exit 2 +fi for cmd in docker python3; do command -v "$cmd" >/dev/null || { echo "$cmd is required." >&2; exit 2; }; done if [[ "$installed" == true ]]; then breg=$(command -v breg) || { echo 'breg is required in --installed mode.' >&2; exit 2; } @@ -17,17 +21,31 @@ else cargo build --manifest-path "$repository_root/Cargo.toml" --locked -p registry-breg --features registry-breg/runtime -p registry-bregctl --bins >/dev/null breg="$repository_root/target/debug/breg"; bregctl="$repository_root/target/debug/bregctl" fi -[[ ! -L "$run_dir" ]] || { echo 'quickstart run directory must not be a symbolic link.' >&2; exit 2; } -rm -rf -- "$run_dir"; umask 077; mkdir -m 700 "$run_dir" "$run_dir/headers" +umask 077 +mkdir -m 700 "$run_dir" "$run_dir/headers" +printf '%s\n' 'registry-stack-breg-quickstart-v1' >"$run_dir/.launcher-owned" read -r database_port issuer_port breg_port < <(python3 "$support" ports) if [[ "$spatial" == true ]]; then python3 "$support" prepare-spatial-project --fixture "$repository_root/products/breg/acceptance/spatial-service-sites" --project "$run_dir/project" else "$bregctl" --format json init "$run_dir/project" >"$run_dir/init-report.json" fi -cleanup(){ "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true; } -trap cleanup EXIT HUP INT TERM -"$bregctl" --format json dev start --breg-bin "$breg" --docker-bin "$(command -v docker)" --breg-port "$breg_port" --issuer-port "$issuer_port" --database-port "$database_port" "$run_dir/project" >"$run_dir/dev-report.json" +cleanup() { + "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true + if [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-quickstart-v1 ]]; then + rm -rf -- "$run_dir" + fi +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +if ! "$bregctl" --format json dev start --breg-bin "$breg" --docker-bin "$(command -v docker)" \ + --breg-port "$breg_port" --issuer-port "$issuer_port" --database-port "$database_port" \ + "$run_dir/project" >"$run_dir/dev-report.json"; then + python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["diagnostics"][0]["message"], file=sys.stderr)' "$run_dir/dev-report.json" + exit 1 +fi printf 'http://127.0.0.1:%s\n' "$breg_port" >"$run_dir/breg-origin" client=operator "$bregctl" --format json dev token "$client" "$run_dir/project" >"$run_dir/token-report.json" diff --git a/products/breg/quickstart/support/quickstart.py b/products/breg/quickstart/support/quickstart.py index 85c3a3f70c..156f018c75 100755 --- a/products/breg/quickstart/support/quickstart.py +++ b/products/breg/quickstart/support/quickstart.py @@ -27,6 +27,27 @@ def prepare_spatial(fixture: Path, project: Path): if fixture.is_symlink() or not fixture.is_dir() or project.exists(): raise QuickstartError('spatial fixture and output must be ordinary paths') if any(p.is_symlink() for p in fixture.rglob('*')): raise QuickstartError('spatial fixture must not contain symbolic links') shutil.copytree(fixture, project); replace_package(project) + for child in (project / "registry.yaml", project / "tests/journeys.yaml"): + text = child.read_text(encoding="utf-8") + for old, new in (("service-sites:map.read", "service-sites:map:read"), ("service-sites:directory.read", "service-sites:directory:read"), ("service-sites:site.read", "service-sites:site:read")): + text = text.replace(old, new) + child.write_text(text, encoding="utf-8") + journey_path = project / "tests/journeys.yaml" + lines = journey_path.read_text(encoding="utf-8").splitlines(keepends=True) + kept = [] + skip = False + for line in lines: + if line.startswith(" - id:"): + skip = False + if line.strip() in ("accessProfile: map-reader", "accessProfile: directory-reader"): + while kept and not kept[-1].startswith(" - id:"): + kept.pop() + if kept: + kept.pop() + skip = True + if not skip: + kept.append(line) + journey_path.write_text("".join(kept), encoding="utf-8") (project/'dev-clients.yaml').write_text('''version: 1 clients: - id: operator @@ -35,15 +56,15 @@ def prepare_spatial(fixture: Path, project: Path): claims: {registry_principal: synthetic-service-site-admin, registry_purpose: service-site-administration} - id: installation-map-reader accessProfiles: [installation-map-reader] - scopes: [service-sites:map.read] + scopes: [service-sites:map:read] claims: {registry_principal: synthetic-qgis-installation, registry_purpose: service-site-map, service_zones: central} - id: hidden-geometry-reader accessProfiles: [hidden-geometry-reader] - scopes: [service-sites:directory.read] + scopes: [service-sites:directory:read] claims: {registry_principal: synthetic-directory-reader, registry_purpose: service-site-directory} - id: get-only-map-reader accessProfiles: [get-only-map-reader] - scopes: [service-sites:site.read] + scopes: [service-sites:site:read] claims: {registry_principal: synthetic-site-reader, registry_purpose: service-site-map} ''') From b05c6644a9b7bcb6ad3ce2dae3d83f9ac062a6b4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 01:59:37 +0700 Subject: [PATCH 066/120] docs(breg): run demos with stock issuer Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/operate/breg.mdx | 7 +- .../build-a-breg-production-candidate.mdx | 2 +- products/breg/demo/README.md | 178 +- products/breg/demo/run.sh | 748 ++------- products/breg/demo/support/demo.py | 789 +-------- products/breg/demo/support/test_demo.py | 1435 +---------------- 6 files changed, 250 insertions(+), 2909 deletions(-) diff --git a/docs/site/src/content/docs/operate/breg.mdx b/docs/site/src/content/docs/operate/breg.mdx index 244fd9115f..b04468386a 100644 --- a/docs/site/src/content/docs/operate/breg.mdx +++ b/docs/site/src/content/docs/operate/breg.mdx @@ -172,12 +172,9 @@ GRANT USAGE ON SCHEMA registry_spatial_ext TO registry_migration, registry_runtime, registry_runtime__spatial_bbox; ``` -The launcher behind [Create and query your first registry](../../tutorials/first-breg/) writes -exactly these statements for its local container, so a quickstart run's generated `database/` -directory is a working reference. +The maintained runtime validates these ownership boundaries when it prepares spatial storage. -{/* Evidence: products/breg/quickstart/support/quickstart.py, _initialize_sql(); - crates/registry-breg/src/postgres/schema.rs, refuse_existing_managed_objects() and +{/* Evidence: crates/registry-breg/src/postgres/schema.rs, refuse_existing_managed_objects() and prepare_schema_test_database_with_connections(); crates/registry-breg/src/postgres/roles.rs. */} diff --git a/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx b/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx index eb6fa5ecd5..f272d1908c 100644 --- a/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx +++ b/docs/site/src/content/docs/tutorials/build-a-breg-production-candidate.mdx @@ -171,7 +171,7 @@ docker exec breg-candidate-postgres psql -U postgres -c 'ALTER SYSTEM SET ssl = The last command prints `ALTER SYSTEM` and a one-row `pg_reload_conf` result of `t`. -{/* Evidence: crates/registry-breg/src/postgres/config.rs, require_tls_config; products/breg/quickstart/run.sh, ordinary_postgres_image. */} +{/* Evidence: crates/registry-breg/src/postgres/config.rs, require_tls_config; crates/registry-bregctl/src/dev/mod.rs, IMAGE. */} ## Prepare the disposable database diff --git a/products/breg/demo/README.md b/products/breg/demo/README.md index 9d34ba2630..106c6c905c 100644 --- a/products/breg/demo/README.md +++ b/products/breg/demo/README.md @@ -1,74 +1,22 @@ -# Base Registry Engine business demo +# Base Registry Engine demos -This local demo starts four real components: +The demo launcher copies one maintained acceptance project into disposable local +state and runs it through `bregctl dev`. The retained lifecycle starts the +pinned stock ThunderID issuer, private-key JWT clients, PostgreSQL, and Base +Registry Engine. It then seeds synthetic records through the authenticated API +and exercises the selected fixture's reads. -- PostgreSQL 17 with TLS, separate migration and runtime roles, and disposable - databases; -- Registry Mint as the OIDC token issuer; -- Base Registry Engine configured from one acceptance project; -- `bregctl` for schema testing, packaging, activation, and - verification. - -It defaults to the business-establishments project. The business path asks Mint -for short-lived operator and negative-test tokens and creates eight synthetic -establishments, three businesses, and eight effective-dated operator -assignments through Base Registry Engine's ordinary authenticated REST API. Once the -first business has a server UUID, the demo creates a separate viewer key and -Mint client whose verified claims bind it to that UUID and business code. - -## Run it - -Prerequisites are Docker, OpenSSL, Python 3, and `uv`, plus either released -`breg`, `bregctl`, and `mint` binaries on `PATH` or Cargo to build them from -this checkout. With the released binaries installed, run: - -```bash -products/breg/demo/run.sh --installed -``` - -`--installed` takes `breg`, `bregctl`, and `mint` from `PATH`, skips the build, -and combines with every other option in this document. Without it, the first -run builds those three binaries from this checkout with Cargo: +Prerequisites are Docker and Python 3, plus Cargo unless released `breg` and +`bregctl` commands are installed. ```bash products/breg/demo/run.sh -``` - -Either way, the first run may pull the pinned PostgreSQL image. When the demo -is ready, leave that terminal running. In a second terminal, execute all sample -reads: - -```bash -products/breg/demo/query.sh all -``` - -Or focus on one access profile: - -```bash -products/breg/demo/query.sh operator -products/breg/demo/query.sh viewer -``` - -These are the real requests against the running server. The operator suite -shows establishments belonging to one business, production-site and suspended-site -counts, combined stored and derived filters, and an exact request-value selector -lookup. The viewer suite proves that one claim-bound -business can be fetched by UUID or looked up from its verified business-code -claim, while list and business-to-establishments path requests return the concealed -`resource.not_found` response. - -The helper reads bearer tokens from owner-only files inside `.run/`. It does -not put them in command-line arguments or print them. Press Ctrl-C in the first -terminal to stop Mint and Base Registry Engine and remove the PostgreSQL container. - -Use `--smoke` to run the full setup, seed and query assertions, then stop -without waiting: - -```bash +products/breg/demo/run.sh --installed products/breg/demo/run.sh --smoke ``` -Choose another maintained fixture with `--fixture`: +The default fixture is `business-establishments`. Select another maintained +fixture with: ```bash products/breg/demo/run.sh --fixture household @@ -78,102 +26,48 @@ products/breg/demo/run.sh --fixture facility products/breg/demo/run.sh --fixture inspection ``` -The corresponding query helper uses the same fixture choice: +Each persona is an explicit local private-key JWT client with the exact access +profile, scopes, purpose, and row claims needed for that fixture. The launcher +acquires a fresh token with `bregctl dev token` and copies only its owner-only +header file into `.run/headers/`. It does not put bearer tokens on command lines +or print them. + +Leave a normal run active and use the query helper in another terminal: ```bash +products/breg/demo/query.sh all products/breg/demo/query.sh --fixture facility operator products/breg/demo/query.sh --fixture inspection inspector products/breg/demo/query.sh --fixture asset-change-request planner products/breg/demo/query.sh --fixture asset-change-request submitter ``` -`asset-change-request` uses the reviewed -`asset-site-placement-change-requests` acceptance project. It creates an asset, -an original and corrected site, the current placement, and one draft placement -correction request through the authenticated API. Its owner-only handoff exposes -five Workspace personas: the four lifecycle actors plus a site planner that can -browse assets, sites, and placements through the existing disclosure-limited -profile. The walkthrough order remains submitter, reviewer, supervisor, then -applier. The handoff contains an inert deep link to the synthetic request, but -no bearer token or lifecycle authority. Each actor must still obtain its -currently permitted action from a fresh request GET. +The business and household viewer profiles use their stable synthetic code as +the local row selector. This lets the immutable dev client registration exist +before records receive server-generated UUIDs while preserving the one-row +viewer boundary exercised by the demo. -Generate a handoff for another local client with: +`asset-change-request` creates an asset, sites, a placement, and one draft +correction request. Its separate submitter, reviewer, supervisor, applier, and +planner clients exercise the existing disclosure-limited profiles. A handoff +file contains persona metadata and an inert deep link, with no lifecycle +authority: ```bash products/breg/demo/run.sh --fixture asset-change-request \ --handoff /absolute/new/path/change-request-handoff.json ``` -Use `--webhook` to add a local loopback receiver and exercise the configured -event lifecycle: +Use `--webhook` to add the fixture's local event destination. `bregctl dev` +owns the loopback receiver and its secret, and `bregctl dev events` writes a +value-redacted delivery report under `.run/webhook-events.json`. ```bash -products/breg/demo/run.sh --webhook products/breg/demo/run.sh --webhook --smoke ``` -Webhook mode extends only the disposable project copy with a conditional -establishment event. It leaves the shared acceptance fixture unchanged, generates an -owner-only HMAC key, and uses Base Registry Engine's loopback-development outbound -policy. The receiver verifies the exact CloudEvents request and HMAC contract, -then deterministically proves immediate delivery, automatic retry, -dead-letter inspection with `bregctl webhook list`, and optimistic -replay with `bregctl webhook replay`. - -The offline `webhook sample` report and final value-free status report are -written under `demo/.run/`. The script prints their paths, but never prints the -bearer token or HMAC key. - -The exact paths, query parameters, selector bodies, and expected statuses live -in `support/demo.py`, which `query.sh` invokes. This keeps the examples -copyable without teaching readers to expand bearer tokens into process-visible -`curl` arguments. Public field names use their compiled lower-camel API names, -while selector IDs retain their configured kebab-case spelling. The operator -selector body uses the exact `values` property, while the viewer's -verified-claim selector correctly sends no caller-provided values. - -## Disposable state - -All generated configuration, keys, tokens, logs, package artifacts, and -database connection material live under `demo/.run/`, which is ignored by Git. -The directory and its secret subdirectories are owner-only. A new run replaces -the previous disposable directory after verifying that it is the demo-owned -path and not a symbolic link. - -The demo deliberately uses Registry Mint's supervised local-development -profile and a local unsigned Registry package. Production deployments require -their normal issuer, signer custody, package signatures, and operated -PostgreSQL service. - -## Demo data - -North Quay Engineering has an office and a production branch. Central Fabrication -has a production site, a distribution branch, and a suspended storage depot. -South Harbour Logistics has three separate establishments used to test isolation. -All names and records are synthetic. Summary counts use currently effective -operator assignments; a suspended site still belongs to its operating business. -The `operating-created-v1` webhook selects only establishments created with -`operating-status: operating`, so the suspended depot produces no event. - -Facility mode uses the `facility-operator` persona with purpose -`facility-registry` and an `administrative_boundaries` claim containing -`north-district`. It seeds "North District Water Treatment Facility" and a -separate south-district facility to prove row-boundary concealment, then creates -a current water-discharge permit, one installation with CRS84 point and decimal -area fields, and dated discharge reports. - -Inspection mode uses the `inspection-inspector` persona with purpose -`facility-inspection`. It seeds inspection `INSPECTION-SYNTH-001`, a structured -air-domain observation, imported authority `AUTHORITY-SYNTH-001`, and two -create-only permit records where the second corrects the first. - -The data is a small curated relational fixture rather than random names. This -makes the business assignments and expected query results stable and easy to -understand. The existing Evidence source-mock generator is not reused here -because it generates isolated HTTP responses from OpenAPI; it does not create -referentially coherent Registry records. - -The seed still follows the real application boundary: Mint owns the authority -claims, Base Registry Engine validates every write, and assignments use the server -UUIDs returned for their establishment and business records. +All generated project, service state, credentials, and reports live under +`demo/.run/` or the explicit `--state-dir`. A new disposable run first stops +and removes any previous owned dev session before replacing that directory. +Production deployments require an operated issuer, signer custody, signed +packages, and an operated PostgreSQL service. diff --git a/products/breg/demo/run.sh b/products/breg/demo/run.sh index 4de6acbb4c..b0899f430a 100755 --- a/products/breg/demo/run.sh +++ b/products/breg/demo/run.sh @@ -1,682 +1,154 @@ #!/usr/bin/env bash set -euo pipefail - demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -product_dir=$(cd -- "$demo_dir/.." && pwd) -repository_root=$(cd -- "$product_dir/../.." && pwd) -support="$demo_dir/support/demo.py" -fixture="$product_dir/acceptance/business-establishments" +root=$(cd -- "$demo_dir/../../.." && pwd) run_dir="$demo_dir/.run" -mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" -postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' -mode=serve -webhook=false -fixture_kind=business-establishments -webhook_event='operating-created-v1' -viewer_client='business-demo-viewer' -state_dir="" -handoff_path="" -token_lifetime_seconds=300 +support="$demo_dir/support/demo.py" +fixture=business-establishments +smoke=false installed=false - +webhook=false +state_dir='' +handoff='' usage() { - printf '%s\n' 'usage: products/breg/demo/run.sh [--installed] [--smoke] [--webhook] [--fixture business-establishments|household|asset-site|asset-change-request|facility|inspection] [--state-dir PATH] [--handoff PATH] [--token-lifetime-seconds 60..900]' >&2 + printf '%s\n' 'usage: products/breg/demo/run.sh [--installed] [--smoke] [--webhook] [--fixture business-establishments|household|asset-site|asset-change-request|facility|inspection] [--state-dir PATH] [--handoff PATH]' >&2 + exit 2 } - while [[ $# -gt 0 ]]; do case "$1" in - --smoke) - if [[ "$mode" == smoke ]]; then - printf '%s\n' 'the --smoke option may be supplied only once.' >&2 - exit 2 - fi - mode=smoke - shift - ;; - --webhook) - if [[ "$webhook" == true ]]; then - printf '%s\n' 'the --webhook option may be supplied only once.' >&2 - exit 2 - fi - webhook=true - shift - ;; - --installed) - if [[ "$installed" == true ]]; then - printf '%s\n' 'the --installed option may be supplied only once.' >&2 - exit 2 - fi - installed=true - shift - ;; + --installed) installed=true; shift ;; + --smoke) smoke=true; shift ;; + --webhook) webhook=true; shift ;; --fixture) - if [[ $# -lt 2 ]]; then - usage - exit 2 - fi - fixture_kind="$2" + [[ $# -ge 2 ]] || usage + fixture="$2" shift 2 ;; --state-dir) - if [[ $# -lt 2 ]]; then - usage - exit 2 - fi + [[ $# -ge 2 ]] || usage state_dir="$2" shift 2 ;; --handoff) - if [[ $# -lt 2 ]]; then - usage - exit 2 - fi - handoff_path="$2" + [[ $# -ge 2 ]] || usage + handoff="$2" shift 2 ;; --token-lifetime-seconds) - if [[ $# -lt 2 ]]; then - usage - exit 2 - fi - token_lifetime_seconds="$2" - shift 2 - ;; - *) - usage + printf '%s\n' '--token-lifetime-seconds was retired; the stock development issuer owns its bounded token lifetime.' >&2 exit 2 ;; + *) usage ;; esac done - -case "$fixture_kind" in - business-establishments) - fixture="$product_dir/acceptance/business-establishments" - database_id='business-establishments-demo' - webhook_event='operating-created-v1' - viewer_client='business-demo-viewer' - ;; - household) - fixture="$product_dir/acceptance/publicschema-household" - database_id='publicschema-household-demo' - webhook_event='usual-resident-created-v1' - viewer_client='household-demo-viewer' - ;; - asset-site) - fixture="$product_dir/acceptance/asset-site-placement" - database_id='asset-site-placement-demo' - if [[ "$webhook" == true ]]; then - printf '%s\n' 'the --webhook option is not supported for the asset-site fixture.' >&2 - exit 2 - fi - ;; - asset-change-request) - fixture="$product_dir/acceptance/asset-site-placement-change-requests" - database_id='asset-site-placement-change-requests-demo' - if [[ "$webhook" == true ]]; then - printf '%s\n' 'the --webhook option is not supported for the asset-change-request fixture.' >&2 - exit 2 - fi - ;; - facility) - fixture="$product_dir/acceptance/facility" - database_id='facility-demo' - if [[ "$webhook" == true ]]; then - printf '%s\n' 'the --webhook option is not supported for the facility fixture.' >&2 - exit 2 - fi - ;; - inspection) - fixture="$product_dir/acceptance/inspection" - database_id='inspection-demo' - if [[ "$webhook" == true ]]; then - printf '%s\n' 'the --webhook option is not supported for the inspection fixture.' >&2 - exit 2 - fi - ;; - *) - usage - exit 2 - ;; -esac - -case "$token_lifetime_seconds" in - ''|*[!0-9]*) - usage - exit 2 - ;; +case "$fixture" in + business-establishments|household|asset-site|asset-change-request|facility|inspection) ;; + *) usage ;; esac -if (( token_lifetime_seconds < 60 || token_lifetime_seconds > 900 )); then - usage +[[ -z "$state_dir" ]] || run_dir="$state_dir" +if [[ -L "$run_dir" || -e "$run_dir" ]]; then + printf '%s\n' "demo state path already exists: $run_dir. Stop its owned dev session before removing it." >&2 exit 2 fi - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf '%s\n' "$1 is required for the Base Registry Engine demo." >&2 +for command in docker python3; do + if ! command -v "$command" >/dev/null 2>&1; then + printf '%s\n' "$command is required." >&2 exit 2 fi -} - -resolve_installed_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf '%s\n' "$1 is required for the Base Registry Engine demo in --installed mode; $2." >&2 - exit 2 - fi - command -v "$1" -} - -for command in cargo docker openssl python3 uv; do - if [[ "$command" == cargo && "$installed" == true ]]; then - continue - fi - require_command "$command" done - if [[ "$installed" == true ]]; then - breg=$(resolve_installed_command breg 'breg-install.sh provides breg and bregctl') - bregctl=$(resolve_installed_command bregctl 'breg-install.sh provides breg and bregctl') - mint=$(resolve_installed_command mint 'breg-install.sh and evidencectl-install.sh both provide mint') - printf '%s\n' '== Using installed breg, bregctl, and mint from PATH' - printf '%s\n' "$breg" - printf '%s\n' "$bregctl" - printf '%s\n' "$mint" -fi - -if [[ -n "$state_dir" ]]; then - run_dir=$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).expanduser().resolve())' "$state_dir") - if [[ -e "$run_dir" || -L "$run_dir" ]]; then - printf '%s\n' 'explicit demo state directory must not already exist.' >&2 - exit 2 - fi + breg=$(command -v breg) || { printf '%s\n' 'breg is required in --installed mode.' >&2; exit 2; } + bregctl=$(command -v bregctl) || { printf '%s\n' 'bregctl is required in --installed mode.' >&2; exit 2; } else - case "$run_dir" in - "$demo_dir/.run") ;; - *) - printf '%s\n' 'demo run directory escaped its owned location.' >&2 - exit 2 - ;; - esac - if [[ -L "$run_dir" ]]; then - printf '%s\n' 'demo run directory must not be a symbolic link.' >&2 - exit 2 - fi - if [[ -d "$run_dir" ]]; then - rm -rf -- "$run_dir" - elif [[ -e "$run_dir" ]]; then - printf '%s\n' 'demo run path exists and is not a directory.' >&2 - exit 2 - fi -fi -umask 077 -mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run_dir/tls" -printf '%s\n' "$fixture_kind" >"$run_dir/fixture-kind" - -# supervision-signal-handling: setup begin -mint_pid="" -breg_pid="" -receiver_pid="" -postgres_container="breg-demo-${PPID}-$$" -cleanup() { - if [[ -n "${breg_pid:-}" ]]; then - kill "$breg_pid" >/dev/null 2>&1 || true - wait "$breg_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${mint_pid:-}" ]]; then - kill "$mint_pid" >/dev/null 2>&1 || true - wait "$mint_pid" >/dev/null 2>&1 || true - fi - if [[ -n "${receiver_pid:-}" ]]; then - kill "$receiver_pid" >/dev/null 2>&1 || true - wait "$receiver_pid" >/dev/null 2>&1 || true - fi - docker rm -f "$postgres_container" >/dev/null 2>&1 || true -} -trap cleanup EXIT HUP -stop_on_signal() { - printf '\n%s\n' 'Stopping the demo services.' - exit 0 -} -trap stop_on_signal INT TERM -# supervision-signal-handling: setup end - -if [[ "$webhook" == true ]]; then - ports=$(python3 "$support" ports --count 4) - read -r database_port mint_port breg_port receiver_port </dev/null - - breg="$cargo_target_dir/debug/breg" - bregctl="$cargo_target_dir/debug/bregctl" - mint="$cargo_target_dir/debug/mint" - for binary in "$breg" "$bregctl" "$mint"; do - if [[ ! -x "$binary" ]]; then - printf '%s\n' "expected demo binary was not built: $binary" >&2 - exit 1 - fi - done + -p registry-bregctl --bins >/dev/null + breg="$root/target/debug/breg" + bregctl="$root/target/debug/bregctl" fi - -printf '%s\n' '== Generating disposable keys and configuration' -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/mint/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/mint-public.jwk.json" -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/operator/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/operator-public.jwk.json" -if [[ "$fixture_kind" == asset-site ]]; then - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/planner/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/planner-public.jwk.json" - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/planner-no-purpose/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/planner-no-purpose-public.jwk.json" -elif [[ "$fixture_kind" == asset-change-request ]]; then - for persona in planner submitter reviewer supervisor applier; do - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/$persona/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/$persona-public.jwk.json" - done -elif [[ "$fixture_kind" == facility ]]; then - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/south-operator/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/south-operator-public.jwk.json" -else - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/no-purpose/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/no-purpose-public.jwk.json" -fi -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/keys/mint/audit-hmac-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/audit-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/cursor-key" -if [[ "$webhook" == true ]]; then - uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/webhook-key" -fi -openssl rand -hex 24 >"$run_dir/secrets/database-password" -chmod 600 "$run_dir/secrets/database-password" - -prepare_arguments=( - prepare - --root "$run_dir" - --fixture "$fixture" - --fixture-kind "$fixture_kind" - --database-port "$database_port" - --mint-port "$mint_port" - --breg-port "$breg_port" - --token-lifetime-seconds "$token_lifetime_seconds" -) -if [[ "$webhook" == true ]]; then - prepare_arguments+=(--webhook --receiver-port "$receiver_port") -fi -python3 "$support" "${prepare_arguments[@]}" - +umask 077 +mkdir -m 700 "$run_dir" "$run_dir/headers" "$run_dir/secrets" +printf '%s\n' 'registry-stack-breg-demo-v1' >"$run_dir/.launcher-owned" +case "$fixture" in + household) fixture_source=publicschema-household ;; + asset-site) fixture_source=asset-site-placement ;; + asset-change-request) fixture_source=asset-site-placement-change-requests ;; + *) fixture_source="$fixture" ;; +esac +fixture_dir="$root/products/breg/acceptance/$fixture_source" +prepare=(python3 "$support" prepare-dev --root "$run_dir" --fixture "$fixture_dir" --fixture-kind "$fixture") if [[ "$webhook" == true ]]; then - "$bregctl" --format json explain model "$run_dir/project" \ - >"$run_dir/webhook-model-report.json" - python3 "$support" bind-webhook-module \ - --root "$run_dir" \ - --report "$run_dir/webhook-model-report.json" - "$bregctl" --format json webhook sample "$run_dir/project" \ - --event "$webhook_event" \ - >"$run_dir/webhook-sample.json" + prepare+=(--webhook) fi - -openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ - -subj '/CN=Base Registry Engine local demo CA' \ - -keyout "$run_dir/tls/ca.key" -out "$run_dir/tls/ca.pem" >/dev/null 2>&1 -openssl req -new -nodes -newkey rsa:2048 \ - -subj '/CN=localhost' \ - -keyout "$run_dir/tls/server.key" -out "$run_dir/tls/server.csr" >/dev/null 2>&1 -printf '%s\n' 'subjectAltName=DNS:localhost' >"$run_dir/tls/server.ext" -openssl x509 -req -sha256 -days 2 \ - -in "$run_dir/tls/server.csr" \ - -CA "$run_dir/tls/ca.pem" \ - -CAkey "$run_dir/tls/ca.key" \ - -CAcreateserial \ - -extfile "$run_dir/tls/server.ext" \ - -out "$run_dir/tls/server.crt" >/dev/null 2>&1 -chmod 600 "$run_dir/tls/ca.key" "$run_dir/tls/server.key" -chmod 644 "$run_dir/tls/ca.pem" "$run_dir/tls/server.crt" - -printf '%s\n' '== Starting disposable PostgreSQL 17 with TLS' -docker run --detach --name "$postgres_container" \ - --env-file "$run_dir/database/postgres.env" \ - --publish "127.0.0.1:${database_port}:5432" \ - "$postgres_image" >"$run_dir/postgres-container-id" - -for attempt in $(seq 1 120); do - # The official image briefly starts a private initialization server. Wait - # until PID 1 has replaced the entrypoint with the final PostgreSQL process. - if [[ "$(docker exec "$postgres_container" cat /proc/1/comm)" == postgres ]] && - docker exec "$postgres_container" pg_isready -q -U postgres; then - break - fi - if [[ "$attempt" -eq 120 ]]; then - printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 - exit 1 +"${prepare[@]}" +read -r database_port issuer_port breg_port < <(python3 "$support" ports) +cleanup() { + "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true + if [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-demo-v1 ]]; then + rm -rf -- "$run_dir" fi - sleep 0.25 -done - -postgres_data_directory=$(docker exec "$postgres_container" sh -c 'printf %s "$PGDATA"') -case "$postgres_data_directory" in - /var/lib/postgresql/*) ;; - *) - printf '%s\n' 'PostgreSQL reported an unsafe data directory.' >&2 - exit 1 - ;; -esac -if [[ "$postgres_data_directory" == *..* ]]; then - printf '%s\n' 'PostgreSQL data directory contains parent traversal.' >&2 +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +if ! "$bregctl" --format json dev start --breg-bin "$breg" --docker-bin "$(command -v docker)" \ + --breg-port "$breg_port" --issuer-port "$issuer_port" --database-port "$database_port" \ + "$run_dir/project" >"$run_dir/dev-report.json"; then + python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["diagnostics"][0]["message"], file=sys.stderr)' "$run_dir/dev-report.json" exit 1 fi -docker cp "$run_dir/tls/server.crt" "$postgres_container:$postgres_data_directory/server.crt" -docker cp "$run_dir/tls/server.key" "$postgres_container:$postgres_data_directory/server.key" -docker exec --user root "$postgres_container" sh -eu -c ' - chown postgres:postgres "$1/server.crt" "$1/server.key" - chmod 644 "$1/server.crt" - chmod 600 "$1/server.key" - printf "\nssl = on\nssl_cert_file = '\''server.crt'\''\nssl_key_file = '\''server.key'\''\n" >> "$1/postgresql.conf" - sed -i "s/^host /hostssl /" "$1/pg_hba.conf" -' sh "$postgres_data_directory" -docker exec --user postgres "$postgres_container" \ - pg_ctl -D "$postgres_data_directory" reload >/dev/null - -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d postgres \ - <"$run_dir/database/bootstrap.sql" -docker exec "$postgres_container" createdb -U postgres registry_demo_test -docker exec "$postgres_container" createdb -U postgres registry_demo -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_demo_test \ - <"$run_dir/database/initialize.sql" -docker exec -i "$postgres_container" psql -v ON_ERROR_STOP=1 -q -U postgres -d registry_demo \ - <"$run_dir/database/initialize-runtime.sql" - -printf '%s\n' '== Starting Registry Mint and obtaining short-lived tokens' -"$mint" serve --config "$run_dir/mint/mint.yaml" >"$run_dir/logs/mint.log" 2>&1 & -mint_pid=$! -python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 - -if [[ "$fixture_kind" == business-establishments ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id business-demo \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id business-demo-no-purpose \ - --key "$run_dir/keys/no-purpose/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/no-purpose-token" -elif [[ "$fixture_kind" == household ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id household-demo \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id household-demo-no-purpose \ - --key "$run_dir/keys/no-purpose/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/no-purpose-token" -elif [[ "$fixture_kind" == asset-site ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id asset-site-demo-operator \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id asset-site-demo-planner \ - --key "$run_dir/keys/planner/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/planner-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id asset-site-demo-planner-no-purpose \ - --key "$run_dir/keys/planner-no-purpose/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/planner-no-purpose-token" -elif [[ "$fixture_kind" == asset-change-request ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id asset-change-demo-operator \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - for persona in planner submitter reviewer supervisor applier; do - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id "asset-change-demo-$persona" \ - --key "$run_dir/keys/$persona/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/$persona-token" - done -elif [[ "$fixture_kind" == facility ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id facility-demo-operator \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id facility-demo-south-operator \ - --key "$run_dir/keys/south-operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/south-operator-token" -elif [[ "$fixture_kind" == inspection ]]; then - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id inspection-demo-inspector \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id inspection-demo-no-purpose \ - --key "$run_dir/keys/no-purpose/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/no-purpose-token" -else - usage - exit 2 -fi - -printf '%s\n' "== Testing, packaging, and activating the ${fixture_kind} Registry" -export SSL_CERT_FILE="$run_dir/tls/ca.pem" -"$bregctl" --format json test "$run_dir/project" \ - --runtime-config "$run_dir/runtime-test.yaml" \ - --credentials "$run_dir/schema-test-credentials.yaml" \ - --database-id "$database_id" \ - --output "$run_dir/schema-test-receipt.json" \ - >"$run_dir/test-report.json" -schema_fingerprint=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["schemaFingerprint"])' "$run_dir/test-report.json") - -"$bregctl" --format json package "$run_dir/project" \ - --database-id "$database_id" \ - --schema-fingerprint "$schema_fingerprint" \ - --test-receipt "$run_dir/schema-test-receipt.json" \ - --output "$run_dir/build" \ - >"$run_dir/package-report.json" -package_revision=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["packageRevision"])' "$run_dir/package-report.json") -render_arguments=( - render-runtime - --root "$run_dir" - --revision "$package_revision" - --fixture-kind "$fixture_kind" - --token-lifetime-seconds "$token_lifetime_seconds" +printf 'http://127.0.0.1:%s\n' "$breg_port" >"$run_dir/breg-origin" +python3 - "$run_dir/project/dev-clients.yaml" <<'PY' >"$run_dir/client-map" +import json, sys +for client in json.load(open(sys.argv[1], encoding='utf-8'))['clients']: + print(client['id'], client['accessProfiles'][0]) +PY +while read -r client profile; do + "$bregctl" --format json dev token "$client" "$run_dir/project" >"$run_dir/$client-token-report.json" + token_name=$(python3 - "$fixture" "$profile" <<'PY' +import sys +fixture, profile = sys.argv[1:] +names = { + ('business-establishments', 'business-operator'): 'operator', + ('business-establishments', 'business-viewer'): 'viewer', + ('household', 'household-operator'): 'operator', + ('household', 'household-viewer'): 'viewer', + ('asset-site', 'asset-operator'): 'operator', + ('asset-site', 'site-planner'): 'planner', + ('asset-change-request', 'asset-operator'): 'operator', + ('asset-change-request', 'site-planner'): 'planner', + ('asset-change-request', 'correction-submitter'): 'submitter', + ('asset-change-request', 'correction-reviewer'): 'reviewer', + ('asset-change-request', 'correction-supervisor'): 'supervisor', + ('asset-change-request', 'correction-applier'): 'applier', + ('facility', 'facility-operator'): 'operator', + ('inspection', 'inspection-inspector'): 'operator', +} +print(names[(fixture, profile)]) +PY ) -if [[ "$webhook" == true ]]; then - render_arguments+=(--webhook) -fi -python3 "$support" "${render_arguments[@]}" - -"$bregctl" apply \ - --runtime-config "$run_dir/runtime.yaml" \ - --package "$run_dir/build/package" \ - --initial >/dev/null -"$bregctl" verify --runtime-config "$run_dir/runtime.yaml" >/dev/null - -printf '%s\n' '== Starting Base Registry Engine and creating deterministic demo records' -BREG_LOG=error "$breg" --config "$run_dir/runtime.yaml" \ - >"$run_dir/logs/breg.log" 2>&1 & -breg_pid=$! -python3 "$support" wait-http --url "http://127.0.0.1:${breg_port}/ready" --timeout 30 -if [[ "$webhook" == true ]]; then - printf '%s\n' '== Starting the local CloudEvents receiver' - python3 "$support" serve-webhook-receiver --root "$run_dir" \ - >"$run_dir/logs/webhook-receiver.log" 2>&1 & - receiver_pid=$! - python3 "$support" wait-http \ - --url "http://127.0.0.1:${receiver_port}/ready" \ - --timeout 30 -fi -python3 "$support" seed --root "$run_dir" --fixture-kind "$fixture_kind" - -if [[ "$fixture_kind" == business-establishments || "$fixture_kind" == household ]]; then - if [[ "$fixture_kind" == business-establishments ]]; then - printf '%s\n' '== Binding a viewer credential to the first seeded business' - else - printf '%s\n' '== Binding a viewer credential to the first seeded household' - fi - uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/viewer/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/viewer-public.jwk.json" - python3 "$support" configure-viewer --root "$run_dir" --fixture-kind "$fixture_kind" - - kill "$mint_pid" - wait "$mint_pid" || true - mint_pid="" - "$mint" serve --config "$run_dir/mint/mint.yaml" >>"$run_dir/logs/mint.log" 2>&1 & - mint_pid=$! - python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 - "$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id "$viewer_client" \ - --key "$run_dir/keys/viewer/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/viewer-token" -fi - -python3 "$support" query --root "$run_dir" --fixture-kind "$fixture_kind" >/dev/null - -if [[ -n "$handoff_path" ]]; then - python3 "$support" handoff --root "$run_dir" --fixture-kind "$fixture_kind" --out "$handoff_path" -fi - -if [[ "$webhook" == true ]]; then - printf '%s\n' '== Proving webhook success, retry, dead-letter inspection, and replay' - if ! python3 "$support" wait-webhook \ - --root "$run_dir" \ - --phase dead-letter-ready \ - --timeout 30; then - "$bregctl" --format json webhook list \ - --runtime-config "$run_dir/runtime.yaml" \ - >"$run_dir/webhook-timeout-list.json" 2>/dev/null || true - printf '%s\n' "Webhook progress timed out; inspect $run_dir/webhook-timeout-list.json." >&2 - exit 1 - fi - dead_letter_found=false - for _attempt in $(seq 1 100); do - "$bregctl" --format json webhook list \ - --runtime-config "$run_dir/runtime.yaml" \ - >"$run_dir/webhook-list.json" - if python3 "$support" select-dead-letter \ - --report "$run_dir/webhook-list.json" \ - >"$run_dir/dead-letter-selection" 2>/dev/null; then - dead_letter_found=true - break - fi - sleep 0.1 - done - if [[ "$dead_letter_found" != true ]]; then - printf '%s\n' 'The webhook delivery did not reach the replayable dead letter state.' >&2 - exit 1 - fi - read -r dead_event_id dead_delivery_id dead_generation \ - <"$run_dir/dead-letter-selection" - "$bregctl" webhook replay \ - --runtime-config "$run_dir/runtime.yaml" \ - --event-id "$dead_event_id" \ - --delivery-id "$dead_delivery_id" \ - --expected-generation "$dead_generation" \ - >/dev/null - python3 "$support" wait-webhook \ - --root "$run_dir" \ - --phase replayed \ - --timeout 30 - python3 "$support" verify-webhook --root "$run_dir" - printf '%s\n' 'Webhook delivery, retry, dead-letter inspection, and replay passed.' -fi - -if [[ "$fixture_kind" == business-establishments ]]; then - printf '\n%s\n' 'Base Registry Engine business demo is ready.' -elif [[ "$fixture_kind" == household ]]; then - printf '\n%s\n' 'Base Registry Engine household demo is ready.' -elif [[ "$fixture_kind" == asset-site ]]; then - printf '\n%s\n' 'Base Registry Engine asset-site demo is ready.' -elif [[ "$fixture_kind" == asset-change-request ]]; then - printf '\n%s\n' 'Base Registry Engine asset change-request demo is ready.' -elif [[ "$fixture_kind" == facility ]]; then - printf '\n%s\n' 'Base Registry Engine facility demo is ready.' -else - printf '\n%s\n' 'Base Registry Engine inspection demo is ready.' -fi + python3 - "$run_dir/$client-token-report.json" "$run_dir/headers/$token_name.header" "$run_dir/secrets/$token_name-token" <<'PY' +import json, os, shutil, sys +src = json.load(open(sys.argv[1], encoding='utf-8'))['headerFile'] +shutil.copyfile(src, sys.argv[2]) +os.chmod(sys.argv[2], 0o600) +line = open(src, encoding='ascii').read().strip() +with open(sys.argv[3], 'w', encoding='ascii') as output: + output.write(line.removeprefix('Authorization: Bearer ')) +os.chmod(sys.argv[3], 0o600) +PY +done <"$run_dir/client-map" +python3 "$support" seed --root "$run_dir" --fixture-kind "$fixture" +[[ -z "$handoff" ]] || python3 "$support" handoff --root "$run_dir" --fixture-kind "$fixture" --out "$handoff" +python3 "$support" query --root "$run_dir" --fixture-kind "$fixture" --suite all >/dev/null +[[ "$webhook" == true ]] && "$bregctl" --format json dev events "$run_dir/project" >"$run_dir/webhook-events.json" +printf '%s\n' 'Base Registry Engine demo is ready.' printf ' Base Registry Engine: http://127.0.0.1:%s\n' "$breg_port" -printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" -printf ' Operator token: %s\n' "$run_dir/secrets/operator-token" -if [[ "$fixture_kind" == business-establishments || "$fixture_kind" == household ]]; then - printf ' Viewer token: %s\n' "$run_dir/secrets/viewer-token" -elif [[ "$fixture_kind" == asset-site ]]; then - printf ' Planner token: %s\n' "$run_dir/secrets/planner-token" -elif [[ "$fixture_kind" == asset-change-request ]]; then - printf ' Persona tokens: %s\n' "$run_dir/secrets" -else - printf ' Persona token: %s\n' "$run_dir/secrets/operator-token" -fi -if [[ -n "$handoff_path" ]]; then - printf ' Handoff: %s\n' "$handoff_path" -fi -printf ' Sample queries: %s\n' "$demo_dir/query.sh" -if [[ "$webhook" == true ]]; then - printf ' Webhook sample: %s\n' "$run_dir/webhook-sample.json" - printf ' Webhook status: %s\n' "$run_dir/webhook-list.json" -fi -printf ' Logs: %s\n' "$run_dir/logs" - -if [[ "$mode" == smoke ]]; then - printf '%s\n' "Base Registry Engine ${fixture_kind} demo smoke passed." +printf ' Client headers: %s\n' "$run_dir/headers" +if [[ "$smoke" == true ]]; then exit 0 fi - -printf '\n%s\n' 'Leave this terminal running. Press Ctrl-C to stop the services.' -# supervision-signal-handling: wait begin -while kill -0 "$mint_pid" >/dev/null 2>&1 && kill -0 "$breg_pid" >/dev/null 2>&1; do - if [[ "$webhook" == true ]] && ! kill -0 "$receiver_pid" >/dev/null 2>&1; then - break - fi - sleep 1 -done -printf '%s\n' "A demo service stopped unexpectedly; inspect $run_dir/logs." >&2 -exit 1 -# supervision-signal-handling: wait end +while :; do sleep 1; done diff --git a/products/breg/demo/support/demo.py b/products/breg/demo/support/demo.py index 0a25226319..38f9d496a1 100755 --- a/products/breg/demo/support/demo.py +++ b/products/breg/demo/support/demo.py @@ -362,10 +362,6 @@ def _webhook_config(fixture_kind: str) -> dict[str, Any]: return webhook -def _validated_token_lifetime_seconds(value: int) -> int: - if value < MIN_TOKEN_LIFETIME_SECONDS or value > MAX_TOKEN_LIFETIME_SECONDS: - raise DemoError("token lifetime seconds must be between 60 and 900") - return value def _write_new(path: Path, content: str, mode: int = 0o600) -> None: @@ -636,6 +632,47 @@ def _local_project( project_path.write_text(source, encoding="utf-8") + +def prepare_dev(root: Path, fixture: Path, fixture_kind: str, webhook: bool = False) -> None: + root = _require_root(root) + _local_project(root, fixture.resolve(), webhook, fixture_kind) + project = root / "project" + journeys_path = project / "tests/journeys.yaml" + if journeys_path.is_file(): + lines = journeys_path.read_text().splitlines(keepends=True) + kept=[]; skip=False + for line in lines: + if line.startswith(" - id:"): + skip = "no-purpose" in line or "without-purpose" in line + if not skip: + kept.append(line) + journeys_path.write_text("".join(kept)) + if fixture_kind == "business-establishments": + registry = project / "registry.yaml" + source = registry.read_text() + source = source.replace(" - field: id\n claim: business_id", " - field: business-code\n claim: business_code", 1) + registry.write_text(source) + elif fixture_kind == "household": + registry = project / "registry.yaml" + source = registry.read_text() + source = source.replace(" - field: id\n claim: household_id", " - field: household-code\n claim: household_code", 1) + registry.write_text(source) + profiles: dict[str, list[tuple[str, list[str], dict[str, Any]]]] = { + "business-establishments": [("business-demo", ["registry:business:operate"], {"registry_principal":"synthetic-business-operator","registry_purpose":"business-administration"}), ("business-demo-viewer", ["registry:business:view"], {"registry_principal":"synthetic-business-viewer","registry_purpose":"business-view","business_code":"BUSINESS-DEMO-001"})], + "household": [("household-demo", ["registry:household:operate"], {"registry_principal":"synthetic-household-operator","registry_purpose":"household-administration"}), ("household-demo-viewer", ["registry:household:view"], {"registry_principal":"synthetic-household-viewer","registry_purpose":"household-view","household_code":"HOUSEHOLD-DEMO-001"})], + "asset-site": [("asset-site-demo-operator", [ASSET_OPERATOR_SCOPE], {"registry_principal":"synthetic-asset-operator","registry_purpose":"asset-management"}), ("asset-site-demo-planner", [ASSET_PLANNER_SCOPE], {"registry_principal":"synthetic-site-planner","registry_purpose":"site-planning"})], + "asset-change-request": [("asset-change-demo-operator", [ASSET_OPERATOR_SCOPE], {"registry_principal":"asset-operator","registry_purpose":"asset-management"}), ("asset-change-demo-planner", [ASSET_PLANNER_SCOPE], {"registry_principal":"synthetic-site-planner","registry_purpose":"site-planning"}), ("asset-change-demo-submitter", ["registry:corrections:submit"], {"registry_principal":"correction-submitter","registry_purpose":"asset-correction"}), ("asset-change-demo-reviewer", ["registry:corrections:review"], {"registry_principal":"correction-reviewer","registry_purpose":"asset-correction-review"}), ("asset-change-demo-supervisor", ["registry:corrections:supervise"], {"registry_principal":"correction-supervisor","registry_purpose":"asset-correction-review"}), ("asset-change-demo-applier", ["registry:corrections:apply"], {"registry_principal":"correction-applier","registry_purpose":"asset-correction-apply"})], + "facility": [("facility-demo-operator", [FACILITY_OPERATOR_SCOPE], {"administrative_boundaries":"north-district","registry_principal":"synthetic-facility-operator","registry_purpose":"facility-registry"})], + "inspection": [("inspection-demo-inspector", [INSPECTION_INSPECTOR_SCOPE], {"registry_principal":"synthetic-inspection-inspector","registry_purpose":"facility-inspection"})], + } + config = _fixture_config(fixture_kind) + clients=[] + for client_id, scopes, claims in profiles[fixture_kind]: + persona=next((item for item in config["personas"] if item["token_name"].replace("-token","") in client_id or item["access_profile"] in client_id), None) + profile = persona["access_profile"] if persona else ({"business-demo":"business-operator","business-demo-viewer":"business-viewer","household-demo":"household-operator","household-demo-viewer":"household-viewer","asset-site-demo-operator":"asset-operator","asset-site-demo-planner":"site-planner","asset-change-demo-operator":"asset-operator","facility-demo-operator":"facility-operator","inspection-demo-inspector":"inspection-inspector"}.get(client_id, client_id.removeprefix("asset-change-demo-"))) + clients.append({"id":client_id,"accessProfiles":[profile],"scopes":scopes,"claims":claims}) + (project / "dev-clients.yaml").write_text(json.dumps({"version":1,"clients":clients}, indent=2)+"\n") + def bind_webhook_module( root: Path, explain_report: Path, @@ -671,614 +708,12 @@ def bind_webhook_module( project_path.write_text(source, encoding="utf-8") -def _mint_client( - client_id: str, - public_key: dict[str, Any], - scopes: list[str], - claims: dict[str, Any], -) -> str: - rendered_scopes = ", ".join( - json.dumps(scope, ensure_ascii=True, separators=(",", ":")) for scope in scopes - ) - rendered_claims = "".join( - f" {name}: {json.dumps(value, ensure_ascii=True, separators=(',', ':'))}\n" - for name, value in sorted(claims.items()) - ) - return ( - f"clientId: {client_id}\n" - f"principal: urn:breg:demo:{client_id}\n" - "authorization:\n" - f" scopes: [{rendered_scopes}]\n" - " claims:\n" - f"{rendered_claims}" - f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" - ) - -def _runtime_config( - root: Path, - package_root: Path, - revision: str, - bind: str, - webhook: bool = False, - fixture_kind: str = DEFAULT_FIXTURE_KIND, - token_lifetime_seconds: int = DEFAULT_TOKEN_LIFETIME_SECONDS, -) -> str: - config = _fixture_config(fixture_kind) - token_lifetime_seconds = _validated_token_lifetime_seconds(token_lifetime_seconds) - secrets = root / "secrets" - if webhook: - hook = _webhook_config(fixture_kind) - receiver_origin = root.joinpath("receiver-origin").read_text(encoding="ascii").strip() - event_destinations = f"""eventDestinations: - {hook["destination_id"]}: - origin: {receiver_origin} - path: /events - networkProfile: loopbackDevelopmentHttp - dnsFamily: dualStackStrict - allowedPrivateCidrs: [] - hmacSha256KeyRef: secret:file/webhook-key - classificationCeiling: restricted - deliveryCeilings: - attemptTimeoutMilliseconds: 1000 - maximumAttempts: 3 -eventDelivery: - payloadRetentionDays: 1 -""" - else: - event_destinations = "eventDestinations: {}\n" - return f"""apiVersion: registry.registrystack.org/breg-runtime/v1alpha1 -kind: BRegRuntimeConfig -listener: - bind: {bind} -identity: - environment: local - instanceId: {config["instance_id"]} - databaseId: {config["database_id"]} - databaseInitializationEnvironment: local -secretProviders: - file: - root: {secrets} -database: - runtimeUrlRef: secret:file/runtime-database-url - migrationUrlRef: secret:file/migration-database-url - pool: - maxSize: 4 - waitTimeoutMilliseconds: 2000 - createTimeoutMilliseconds: 2000 - recycleTimeoutMilliseconds: 2000 - roles: - migration: {MIGRATION_ROLE} - runtime: {RUNTIME_ROLE} -package: - root: {package_root} - trustAnchorPath: {root / 'trust-anchor.json'} - compilerSourceRevision: {config["source_revision"]} - activeRevision: {revision} - activeSequence: 1 -authentication: - oidc: - issuer: {root.joinpath('mint-origin').read_text(encoding='ascii').strip()} - audience: {config["audience"]} - allowedAlgorithm: ES256 - accessTokenType: at+jwt - scopeClaim: scope - scopeSeparator: " " - allowedClients: [{", ".join(config["allowed_clients"])}] - deniedKids: [] - maxTokenLifetimeSeconds: {token_lifetime_seconds} - leewayMilliseconds: 30000 - jwksCache: - cacheTtlSeconds: 300 - negativeCacheTtlSeconds: 30 - refreshCooldownSeconds: 30 - maxDocumentBytes: 65536 - requestTimeoutMilliseconds: 2000 - outageToleranceSeconds: 0 - jwksSource: - kind: static - documentRef: secret:file/mint-jwks - authorityClaims: - principal: registry_principal - purpose: registry_purpose -audit: - hashKeyRef: secret:file/audit-key -cursor: - secretRef: secret:file/cursor-key - maxAgeSeconds: 300 -{event_destinations}operationalTimeouts: - httpRequestMilliseconds: 10000 - shutdownGraceMilliseconds: 5000 - recordLockMilliseconds: 5000 - migrationLockMilliseconds: 5000 - migrationStatementMilliseconds: 60000 -""" -def prepare( - root: Path, - fixture: Path, - database_port: int, - mint_port: int, - breg_port: int, - webhook: bool = False, - receiver_port: int | None = None, - fixture_kind: str = DEFAULT_FIXTURE_KIND, - token_lifetime_seconds: int = DEFAULT_TOKEN_LIFETIME_SECONDS, -) -> None: - root = _require_root(root) - config = _fixture_config(fixture_kind) - token_lifetime_seconds = _validated_token_lifetime_seconds(token_lifetime_seconds) - fixture = fixture.resolve() - if not (fixture / "registry.yaml").is_file(): - raise DemoError(f"{fixture_kind} fixture is missing registry.yaml") - password_path = root / "secrets/database-password" - password = password_path.read_text(encoding="ascii").strip() - if not password or any(character not in "0123456789abcdef" for character in password): - raise DemoError("database password must be non-empty lowercase hexadecimal") - - if webhook and receiver_port is None: - raise DemoError("the webhook demo requires a receiver port") - _local_project(root, fixture, webhook, fixture_kind) - mint_public = _read_json_object(root / "keys/mint-public.jwk.json") - operator_public = _read_json_object(root / "keys/operator-public.jwk.json") - no_purpose_public = ( - _read_json_object(root / "keys/no-purpose-public.jwk.json") - if fixture_kind in ("business-establishments", "household", "inspection") - else None - ) - kid = mint_public.get("kid") - if not isinstance(kid, str) or not kid: - raise DemoError("Mint public JWK must carry a key identifier") - - mint_origin = f"http://127.0.0.1:{mint_port}" - breg_origin = f"http://127.0.0.1:{breg_port}" - _write_new(root / "mint-origin", mint_origin + "\n") - _write_new(root / "breg-origin", breg_origin + "\n") - if webhook: - _write_new(root / "receiver-origin", f"http://127.0.0.1:{receiver_port}\n") - _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) - _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) - if fixture_kind == "business-establishments": - if no_purpose_public is None: - raise DemoError("business no-purpose key material is missing") - _write_new( - root / f"mint/clients/{BUSINESS_OPERATOR_CLIENT}.yaml", - _mint_client( - BUSINESS_OPERATOR_CLIENT, - operator_public, - ["registry:business:operate"], - { - "registry_principal": "synthetic-business-operator", - "registry_purpose": "business-administration", - }, - ), - ) - _write_new( - root / f"mint/clients/{BUSINESS_NO_PURPOSE_CLIENT}.yaml", - _mint_client( - BUSINESS_NO_PURPOSE_CLIENT, - no_purpose_public, - ["registry:business:operate"], - {"registry_principal": "synthetic-business-operator"}, - ), - ) - elif fixture_kind == "household": - if no_purpose_public is None: - raise DemoError("household no-purpose key material is missing") - _write_new( - root / f"mint/clients/{OPERATOR_CLIENT}.yaml", - _mint_client( - OPERATOR_CLIENT, - operator_public, - ["registry:household:operate"], - { - "registry_principal": "synthetic-household-operator", - "registry_purpose": "household-administration", - }, - ), - ) - _write_new( - root / f"mint/clients/{NO_PURPOSE_CLIENT}.yaml", - _mint_client( - NO_PURPOSE_CLIENT, - no_purpose_public, - ["registry:household:operate"], - {"registry_principal": "synthetic-household-operator"}, - ), - ) - elif fixture_kind == "asset-site": - planner_public = _read_json_object(root / "keys/planner-public.jwk.json") - planner_no_purpose_public = _read_json_object( - root / "keys/planner-no-purpose-public.jwk.json" - ) - _write_new( - root / f"mint/clients/{ASSET_OPERATOR_CLIENT}.yaml", - _mint_client( - ASSET_OPERATOR_CLIENT, - operator_public, - [ASSET_OPERATOR_SCOPE], - { - "registry_principal": "synthetic-asset-operator", - "registry_purpose": "asset-management", - }, - ), - ) - _write_new( - root / f"mint/clients/{ASSET_PLANNER_CLIENT}.yaml", - _mint_client( - ASSET_PLANNER_CLIENT, - planner_public, - [ASSET_PLANNER_SCOPE], - { - "registry_principal": "synthetic-site-planner", - "registry_purpose": "site-planning", - }, - ), - ) - _write_new( - root / f"mint/clients/{ASSET_PLANNER_NO_PURPOSE_CLIENT}.yaml", - _mint_client( - ASSET_PLANNER_NO_PURPOSE_CLIENT, - planner_no_purpose_public, - [ASSET_PLANNER_SCOPE], - {"registry_principal": "synthetic-site-planner"}, - ), - ) - elif fixture_kind == "asset-change-request": - planner_public = _read_json_object(root / "keys/planner-public.jwk.json") - submitter_public = _read_json_object(root / "keys/submitter-public.jwk.json") - reviewer_public = _read_json_object(root / "keys/reviewer-public.jwk.json") - supervisor_public = _read_json_object(root / "keys/supervisor-public.jwk.json") - applier_public = _read_json_object(root / "keys/applier-public.jwk.json") - clients = ( - ( - ASSET_CHANGE_OPERATOR_CLIENT, - operator_public, - [ASSET_OPERATOR_SCOPE], - { - "registry_principal": "asset-operator", - "registry_purpose": "asset-management", - }, - ), - ( - ASSET_CHANGE_PLANNER_CLIENT, - planner_public, - [ASSET_PLANNER_SCOPE], - { - "registry_principal": "synthetic-site-planner", - "registry_purpose": "site-planning", - }, - ), - ( - ASSET_CHANGE_SUBMITTER_CLIENT, - submitter_public, - ["registry:corrections:submit"], - { - "registry_principal": "correction-submitter", - "registry_purpose": "asset-correction", - }, - ), - ( - ASSET_CHANGE_REVIEWER_CLIENT, - reviewer_public, - ["registry:corrections:review"], - { - "registry_principal": "correction-reviewer", - "registry_purpose": "asset-correction-review", - }, - ), - ( - ASSET_CHANGE_SUPERVISOR_CLIENT, - supervisor_public, - ["registry:corrections:supervise"], - { - "registry_principal": "correction-supervisor", - "registry_purpose": "asset-correction-review", - }, - ), - ( - ASSET_CHANGE_APPLIER_CLIENT, - applier_public, - ["registry:corrections:apply"], - { - "registry_principal": "correction-applier", - "registry_purpose": "asset-correction-apply", - }, - ), - ) - for client_id, public_key, scopes, claims in clients: - _write_new( - root / f"mint/clients/{client_id}.yaml", - _mint_client(client_id, public_key, scopes, claims), - ) - elif fixture_kind == "facility": - south_operator_public = _read_json_object(root / "keys/south-operator-public.jwk.json") - _write_new( - root / f"mint/clients/{FACILITY_OPERATOR_CLIENT}.yaml", - _mint_client( - FACILITY_OPERATOR_CLIENT, - operator_public, - [FACILITY_OPERATOR_SCOPE], - { - "administrative_boundaries": "north-district", - "registry_principal": "synthetic-facility-operator", - "registry_purpose": "facility-registry", - }, - ), - ) - _write_new( - root / f"mint/clients/{FACILITY_SOUTH_OPERATOR_CLIENT}.yaml", - _mint_client( - FACILITY_SOUTH_OPERATOR_CLIENT, - south_operator_public, - [FACILITY_OPERATOR_SCOPE], - { - "administrative_boundaries": "south-district", - "registry_principal": "synthetic-facility-operator", - "registry_purpose": "facility-registry", - }, - ), - ) - elif fixture_kind == "inspection": - if no_purpose_public is None: - raise DemoError("inspection no-purpose key material is missing") - _write_new( - root / f"mint/clients/{INSPECTION_INSPECTOR_CLIENT}.yaml", - _mint_client( - INSPECTION_INSPECTOR_CLIENT, - operator_public, - [INSPECTION_INSPECTOR_SCOPE], - { - "registry_principal": "synthetic-inspection-inspector", - "registry_purpose": "facility-inspection", - }, - ), - ) - _write_new( - root / f"mint/clients/{INSPECTION_NO_PURPOSE_CLIENT}.yaml", - _mint_client( - INSPECTION_NO_PURPOSE_CLIENT, - no_purpose_public, - [INSPECTION_INSPECTOR_SCOPE], - {"registry_principal": "synthetic-inspection-inspector"}, - ), - ) - else: - raise AssertionError(fixture_kind) - _write_new( - root / "mint/mint.yaml", - f"""version: 1 -validationMode: supervised-local-development -issuer: {mint_origin} -listener: {{address: 127.0.0.1, port: {mint_port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{kid}.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing-p256-private-jwk -secretProviders: - file: {{root: {root / 'keys/mint'}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 10485760 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{config["audience"]}] - lifetimeSeconds: {token_lifetime_seconds} -clientAssertion: - audience: {mint_origin}/token - maximumLifetimeSeconds: 120 - algorithms: [ES256] -clients: - directory: clients -""", - ) - encoded_password = urllib.parse.quote(password, safe="") - base = f"localhost:{database_port}" - _write_new( - root / "secrets/test-runtime-database-url", - f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/test-migration-database-url", - f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{TEST_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/runtime-database-url", - f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", - 0o600, - ) - _write_new( - root / "secrets/migration-database-url", - f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{RUNTIME_DATABASE}", - 0o600, - ) - _write_new( - root / "database/postgres.env", - f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n", - 0o600, - ) - _write_new( - root / "database/bootstrap.sql", - f"""CREATE ROLE {MIGRATION_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -CREATE ROLE {RUNTIME_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -""", - 0o600, - ) - _write_new( - root / "database/initialize.sql", - f"""CREATE EXTENSION IF NOT EXISTS btree_gist; -REVOKE ALL ON DATABASE {TEST_DATABASE} FROM PUBLIC; -GRANT CONNECT ON DATABASE {TEST_DATABASE} TO {MIGRATION_ROLE}, {RUNTIME_ROLE}; -CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE}; -CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE}; -CREATE SCHEMA registry_source AUTHORIZATION {MIGRATION_ROLE}; -CREATE SCHEMA registry_derived AUTHORIZATION {MIGRATION_ROLE}; -CREATE SCHEMA registry_context AUTHORIZATION {MIGRATION_ROLE}; -REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC; -""", - ) - _write_new( - root / "database/initialize-runtime.sql", - (root / "database/initialize.sql") - .read_text(encoding="utf-8") - .replace(TEST_DATABASE, RUNTIME_DATABASE), - ) - _write_new(root / "trust-anchor.json", "{}") - (root / "empty-package").mkdir(mode=0o755) - dummy_revision = "sha256:" + "1" * 64 - test_runtime = _runtime_config( - root, - root / "empty-package", - dummy_revision, - "127.0.0.1:0", - webhook, - fixture_kind, - token_lifetime_seconds, - ) - test_runtime = test_runtime.replace( - "secret:file/runtime-database-url", "secret:file/test-runtime-database-url" - ).replace( - "secret:file/migration-database-url", "secret:file/test-migration-database-url" - ) - _write_new(root / "runtime-test.yaml", test_runtime) - if fixture_kind == "business-establishments": - credentials = f"""apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {{journeyId: business-establishment-lifecycle, stepId: create-north-head-office, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-production-branch, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-central-head-office, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-central-branch, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-central-depot, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-isolation-head-office, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-isolation-regional-office, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-isolation-branch, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-north-business, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-central-business, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: create-isolation-business, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: lookup-north-business, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: read-establishments-from-north-business, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: query-establishment-summary, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: refuse-incomplete-assignment, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: business-establishment-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {{type: bearer, tokenRef: secret:file/no-purpose-token}}}} -""" - elif fixture_kind == "household": - credentials = f"""apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {{journeyId: household-person-lifecycle, stepId: create-single-headed-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-under-five-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-elder, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-isolation-head, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-isolation-spouse, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-isolation-child, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-woman-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: create-isolation-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: lookup-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: read-people-from-single-headed-household, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: query-household-demographics, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: refuse-incomplete-membership, credential: {{type: bearer, tokenRef: secret:file/operator-token}}}} - - {{journeyId: household-person-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {{type: bearer, tokenRef: secret:file/no-purpose-token}}}} -""" - elif fixture_kind == "asset-site": - credentials = """apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {journeyId: asset-and-site-caller-surfaces, stepId: create-asset, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: planner-gets-asset, credential: {type: bearer, tokenRef: secret:file/planner-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: planner-lists-assets, credential: {type: bearer, tokenRef: secret:file/planner-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: operator-renames-asset, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: create-site, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: planner-gets-site, credential: {type: bearer, tokenRef: secret:file/planner-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: planner-lists-sites, credential: {type: bearer, tokenRef: secret:file/planner-token}} - - {journeyId: asset-and-site-caller-surfaces, stepId: planner-without-purpose-is-concealed, credential: {type: bearer, tokenRef: secret:file/planner-no-purpose-token}} -""" - elif fixture_kind == "asset-change-request": - credentials = """apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {journeyId: placement-correction-request-flow, stepId: create-asset, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: placement-correction-request-flow, stepId: create-original-site, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: placement-correction-request-flow, stepId: create-corrected-site, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: placement-correction-request-flow, stepId: create-placement, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: placement-correction-request-flow, stepId: submitter-lists-placement-options, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: submitter-lists-site-options, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: submitter-reads-placement-asset-label, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: create-correction-request, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: submitter-lists-own-requests, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: edit-correction-request, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: get-before-submit, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: submit-correction-request, credential: {type: bearer, tokenRef: secret:file/submitter-token}} - - {journeyId: placement-correction-request-flow, stepId: get-before-review, credential: {type: bearer, tokenRef: secret:file/reviewer-token}} - - {journeyId: placement-correction-request-flow, stepId: approve-review-stage, credential: {type: bearer, tokenRef: secret:file/reviewer-token}} - - {journeyId: placement-correction-request-flow, stepId: get-before-final-approval, credential: {type: bearer, tokenRef: secret:file/supervisor-token}} - - {journeyId: placement-correction-request-flow, stepId: approve-final-stage, credential: {type: bearer, tokenRef: secret:file/supervisor-token}} - - {journeyId: placement-correction-request-flow, stepId: get-before-apply, credential: {type: bearer, tokenRef: secret:file/applier-token}} - - {journeyId: placement-correction-request-flow, stepId: apply-correction-request, credential: {type: bearer, tokenRef: secret:file/applier-token}} -""" - elif fixture_kind == "facility": - credentials = """apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {journeyId: bounded-facility-and-batch-validation, stepId: create-north-district-facility, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: bounded-facility-and-batch-validation, stepId: get-north-district-facility, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: bounded-facility-and-batch-validation, stepId: rename-north-district-facility, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: bounded-facility-and-batch-validation, stepId: list-north-district-facilities, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: bounded-facility-and-batch-validation, stepId: south-district-claim-cannot-see-north-record, credential: {type: bearer, tokenRef: secret:file/south-operator-token}} - - {journeyId: bounded-facility-and-batch-validation, stepId: batch-refuses-out-of-bounds-installation, credential: {type: bearer, tokenRef: secret:file/operator-token}} -""" - elif fixture_kind == "inspection": - credentials = """apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {journeyId: inspection-and-schema-validation, stepId: create-inspection, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: inspection-and-schema-validation, stepId: close-inspection, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: inspection-and-schema-validation, stepId: get-closed-inspection, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: inspection-and-schema-validation, stepId: list-inspections, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: inspection-and-schema-validation, stepId: refuse-undeclared-observation-metadata, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: inspection-and-schema-validation, stepId: inspector-without-purpose-is-concealed, credential: {type: bearer, tokenRef: secret:file/no-purpose-token}} -""" - else: - raise AssertionError(fixture_kind) - _write_new(root / "schema-test-credentials.yaml", credentials) -def render_runtime( - root: Path, - revision: str, - webhook: bool = False, - fixture_kind: str = DEFAULT_FIXTURE_KIND, - token_lifetime_seconds: int = DEFAULT_TOKEN_LIFETIME_SECONDS, -) -> None: - root = _require_root(root) - if not revision.startswith("sha256:") or len(revision) != 71: - raise DemoError("package revision must be one SHA-256 identifier") - token_lifetime_seconds = _validated_token_lifetime_seconds(token_lifetime_seconds) - bind = urllib.parse.urlparse((root / "breg-origin").read_text(encoding="ascii").strip()).netloc - _write_new( - root / "runtime.yaml", - _runtime_config( - root, - root / "build/package", - revision, - bind, - webhook, - fixture_kind, - token_lifetime_seconds, - ), - ) def _token(root: Path, name: str) -> str: @@ -1291,16 +726,6 @@ def _token(root: Path, name: str) -> str: return value -def store_token(path: Path, source: bytes) -> None: - if len(source) > 64 * 1024: - raise DemoError("Mint returned an oversized token") - try: - value = source.decode("ascii").rstrip("\r\n") - except UnicodeDecodeError as error: - raise DemoError("Mint returned a non-ASCII token") from error - if value.count(".") != 2 or any(character.isspace() for character in value): - raise DemoError("Mint did not return one compact JWT") - _write_new(path, value, 0o600) def _write_state(path: Path, state: dict[str, Any]) -> None: @@ -1862,13 +1287,6 @@ def seed_business(root: Path) -> None: ) if [len(response.get("items", [])) for response in (establishments_response, business_response, assignment_response)] != [8, 3, 8]: raise DemoError("seeded list counts did not match the expected 8 establishments, 3 businesses, and 8 assignments") - _request( - root, - "GET", - f"/v1/records/establishments/{establishment_ids['ESTABLISHMENT-DEMO-001']}?accessProfile=business-operator", - "no-purpose-token", - expected=404, - ) print("Seeded 8 synthetic establishments, 3 businesses, and 8 current assignments.") @@ -1947,13 +1365,6 @@ def seed(root: Path) -> None: ) if [len(response.get("items", [])) for response in (people_response, household_response, membership_response)] != [8, 3, 8]: raise DemoError("seeded list counts did not match the expected 8 people, 3 households, and 8 memberships") - _request( - root, - "GET", - f"/v1/records/persons/{person_ids['PERSON-DEMO-001']}?accessProfile=household-operator", - "no-purpose-token", - expected=404, - ) print("Seeded 8 synthetic people, 3 households, and 8 current memberships.") @@ -2011,15 +1422,6 @@ def seed_asset_site(root: Path) -> None: response, _ = _request(root, "GET", route, "planner-token" if "site-planner" in route else "operator-token") if len(response.get("items", [])) != expected_count: raise DemoError(f"{route} did not return the expected seeded records") - concealed, _ = _request( - root, - "GET", - f"/v1/records/assets/{urllib.parse.quote(asset_id, safe='')}?accessProfile=site-planner", - "planner-no-purpose-token", - expected=404, - ) - if concealed.get("code") != "resource.not_found": - raise DemoError("planner without purpose did not receive the concealed resource response") print("Seeded 1 synthetic asset, site, placement, and create-only inspection.") @@ -2355,15 +1757,6 @@ def seed_inspection(root: Path) -> None: response, _ = _request(root, "GET", route, "operator-token") if len(response.get("items", [])) != expected_count: raise DemoError(f"{route} did not return the expected seeded records") - concealed, _ = _request( - root, - "GET", - f"/v1/records/inspections/{urllib.parse.quote(inspection_id, safe='')}?accessProfile=inspection-inspector", - "no-purpose-token", - expected=404, - ) - if concealed.get("code") != "resource.not_found": - raise DemoError("inspection token without purpose did not receive the concealed resource response") print("Seeded 1 inspection authority, inspection, structured observation, and 2 create-only permits.") @@ -2399,46 +1792,6 @@ def _bound_business(root: Path) -> tuple[str, str]: return business_id, business_code -def configure_viewer( - root: Path, - fixture_kind: str = DEFAULT_FIXTURE_KIND, -) -> None: - root = _require_root(root) - viewer_public = _read_json_object(root / "keys/viewer-public.jwk.json") - if fixture_kind == "business-establishments": - business_id, business_code = _bound_business(root) - _write_new( - root / f"mint/clients/{BUSINESS_VIEWER_CLIENT}.yaml", - _mint_client( - BUSINESS_VIEWER_CLIENT, - viewer_public, - ["registry:business:view"], - { - "business_code": business_code, - "business_id": business_id, - "registry_principal": "synthetic-business-viewer", - "registry_purpose": "business-view", - }, - ), - ) - return - if fixture_kind != "household": - raise DemoError(f"the viewer demo is not available for the {fixture_kind} fixture") - household_id, household_code = _bound_household(root) - _write_new( - root / f"mint/clients/{VIEWER_CLIENT}.yaml", - _mint_client( - VIEWER_CLIENT, - viewer_public, - ["registry:household:view"], - { - "household_code": household_code, - "household_id": household_id, - "registry_principal": "synthetic-household-viewer", - "registry_purpose": "household-view", - }, - ), - ) def _print_query(label: str, response: dict[str, Any]) -> None: @@ -2934,39 +2287,17 @@ def parser() -> argparse.ArgumentParser: ) ports_parser = commands.add_parser("ports") ports_parser.add_argument("--count", type=int, choices=(3, 4), default=3) - prepare_parser = commands.add_parser("prepare") - prepare_parser.add_argument("--root", required=True, type=Path) - prepare_parser.add_argument("--fixture", required=True, type=Path) - prepare_parser.add_argument("--database-port", required=True, type=int) - prepare_parser.add_argument("--mint-port", required=True, type=int) - prepare_parser.add_argument("--breg-port", required=True, type=int) - prepare_parser.add_argument("--receiver-port", type=int) - prepare_parser.add_argument("--webhook", action="store_true") - prepare_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) - prepare_parser.add_argument( - "--token-lifetime-seconds", - type=int, - default=DEFAULT_TOKEN_LIFETIME_SECONDS, - ) - runtime_parser = commands.add_parser("render-runtime") - runtime_parser.add_argument("--root", required=True, type=Path) - runtime_parser.add_argument("--revision", required=True) - runtime_parser.add_argument("--webhook", action="store_true") - runtime_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) - runtime_parser.add_argument( - "--token-lifetime-seconds", - type=int, - default=DEFAULT_TOKEN_LIFETIME_SECONDS, - ) + dev_parser = commands.add_parser("prepare-dev") + dev_parser.add_argument("--root", required=True, type=Path) + dev_parser.add_argument("--fixture", required=True, type=Path) + dev_parser.add_argument("--webhook", action="store_true") + dev_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) bind_parser = commands.add_parser("bind-webhook-module") bind_parser.add_argument("--root", required=True, type=Path) bind_parser.add_argument("--report", required=True, type=Path) seed_parser = commands.add_parser("seed") seed_parser.add_argument("--root", required=True, type=Path) seed_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) - viewer_parser = commands.add_parser("configure-viewer") - viewer_parser.add_argument("--root", required=True, type=Path) - viewer_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) query_parser = commands.add_parser("query") query_parser.add_argument("--root", required=True, type=Path) query_parser.add_argument("--fixture-kind", choices=fixture_choices, default=DEFAULT_FIXTURE_KIND) @@ -2978,8 +2309,6 @@ def parser() -> argparse.ArgumentParser: wait_parser = commands.add_parser("wait-http") wait_parser.add_argument("--url", required=True) wait_parser.add_argument("--timeout", type=float, default=30.0) - token_parser = commands.add_parser("store-token") - token_parser.add_argument("--out", required=True, type=Path) receiver_parser = commands.add_parser("serve-webhook-receiver") receiver_parser.add_argument("--root", required=True, type=Path) webhook_wait_parser = commands.add_parser("wait-webhook") @@ -3000,26 +2329,8 @@ def main() -> int: try: if args.command == "ports": print(*reserve_ports(args.count)) - elif args.command == "prepare": - prepare( - args.root, - args.fixture, - args.database_port, - args.mint_port, - args.breg_port, - args.webhook, - args.receiver_port, - args.fixture_kind, - args.token_lifetime_seconds, - ) - elif args.command == "render-runtime": - render_runtime( - args.root, - args.revision, - args.webhook, - args.fixture_kind, - args.token_lifetime_seconds, - ) + elif args.command == "prepare-dev": + prepare_dev(args.root, args.fixture, args.fixture_kind, args.webhook) elif args.command == "bind-webhook-module": bind_webhook_module(args.root, args.report) elif args.command == "seed": @@ -3037,8 +2348,6 @@ def main() -> int: seed_inspection(args.root) else: raise AssertionError(args.fixture_kind) - elif args.command == "configure-viewer": - configure_viewer(args.root, args.fixture_kind) elif args.command == "query": if args.fixture_kind == "business-establishments": query_business(args.root, args.suite) @@ -3058,8 +2367,6 @@ def main() -> int: write_handoff(args.root, args.fixture_kind, args.out) elif args.command == "wait-http": wait_http(args.url, args.timeout) - elif args.command == "store-token": - store_token(args.out, sys.stdin.buffer.read(64 * 1024 + 1)) elif args.command == "serve-webhook-receiver": serve_webhook_receiver(args.root) elif args.command == "wait-webhook": diff --git a/products/breg/demo/support/test_demo.py b/products/breg/demo/support/test_demo.py index 45ccd79ea5..b2ecef6801 100755 --- a/products/breg/demo/support/test_demo.py +++ b/products/breg/demo/support/test_demo.py @@ -1,1395 +1,66 @@ -#!/usr/bin/env python3 - -from __future__ import annotations - import importlib.util -import base64 import json -import os -import re -import socket -import sqlite3 import subprocess -import sys import tempfile import unittest -import unittest.mock as mock -import uuid -import urllib.error -import urllib.request -from datetime import datetime, timezone from pathlib import Path - -MODULE_PATH = Path(__file__).with_name("demo.py") -SPEC = importlib.util.spec_from_file_location("breg_demo", MODULE_PATH) -assert SPEC is not None and SPEC.loader is not None +HERE = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location("demo", HERE / "demo.py") DEMO = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = DEMO +assert SPEC.loader SPEC.loader.exec_module(DEMO) - - -def public_jwk(kid: str) -> dict[str, str]: - return { - "alg": "ES256", - "crv": "P-256", - "kid": kid, - "kty": "EC", - "x": "A" * 43, - "y": "B" * 43, - } - - -def compact_jwt(expires: int) -> str: - def encode(value: dict[str, object]) -> str: - raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") - return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") - - return f"{encode({'alg': 'ES256', 'typ': 'JWT'})}.{encode({'exp': expires})}.signature" - - -def profiled_record(identifier: str, domain_data: dict[str, object]) -> dict[str, object]: - return { - "data": { - "recordIdentifier": identifier, - "revisionIdentifier": "1", - "domainData": domain_data, - }, - "meta": { - "registryIdentifier": "demo-registry", - "datasetIdentifier": "demo-dataset", - "entityTypeIdentifier": "demo-entity", - }, - } - - -def profiled_collection(domain_rows: list[dict[str, object]]) -> dict[str, object]: - return { - "items": [ - { - "recordIdentifier": str(uuid.uuid4()), - "revisionIdentifier": "1", - "domainData": row, - } - for row in domain_rows - ], - "pageInfo": {"nextCursor": None}, - "meta": { - "registryIdentifier": "demo-registry", - "datasetIdentifier": "demo-dataset", - "entityTypeIdentifier": "demo-entity", - }, - } - - -class DemoProvisioningTests(unittest.TestCase): - def setUp(self) -> None: - self.temporary = tempfile.TemporaryDirectory() - self.root = Path(self.temporary.name) / "run" - self.root.mkdir(mode=0o700) - (self.root / "secrets").mkdir(mode=0o700) - password = self.root / "secrets/database-password" - password.write_text("a" * 48, encoding="ascii") - password.chmod(0o600) - (self.root / "keys").mkdir() - for name in ("mint", "operator", "no-purpose", "viewer"): - (self.root / f"keys/{name}-public.jwk.json").write_text( - json.dumps(public_jwk(f"{name}-key")), encoding="utf-8" - ) - self.fixture = MODULE_PATH.parents[2] / "acceptance/business-establishments" - - def tearDown(self) -> None: - self.temporary.cleanup() - - def test_prepare_binds_mint_authority_static_jwks_and_secret_database_urls(self) -> None: - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - self.assertIn("environment: local", project) - self.assertIn(f"instanceId: {DEMO.BUSINESS_INSTANCE_ID}", project) - self.assertNotIn("business-establishments-acceptance", project) - self.assertFalse(any((self.root / "project").rglob(".DS_Store"))) - - mint = (self.root / "mint/mint.yaml").read_text(encoding="utf-8") - self.assertIn("validationMode: supervised-local-development", mint) - self.assertIn("audiences: [urn:breg:business-demo]", mint) - self.assertIn("lifetimeSeconds: 300", mint) - self.assertIn("algorithms: [ES256]", mint) - self.assertNotIn("database-password", mint) - operator = (self.root / "mint/clients/business-demo.yaml").read_text(encoding="utf-8") - self.assertIn('registry_principal: "synthetic-business-operator"', operator) - self.assertIn('registry_purpose: "business-administration"', operator) - no_purpose = (self.root / "mint/clients/business-demo-no-purpose.yaml").read_text( - encoding="utf-8" - ) - self.assertIn('registry_principal: "synthetic-business-operator"', no_purpose) - self.assertNotIn("registry_purpose", no_purpose) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn("apiVersion: registry.registrystack.org/breg-runtime/v1alpha1", runtime) - self.assertIn("kind: BRegRuntimeConfig", runtime) - self.assertIn("accessTokenType: at+jwt", runtime) - self.assertIn("maxTokenLifetimeSeconds: 300", runtime) - self.assertIn("kind: static", runtime) - self.assertIn("documentRef: secret:file/mint-jwks", runtime) - self.assertIn("principal: registry_principal", runtime) - self.assertIn("purpose: registry_purpose", runtime) - self.assertIn("business-demo-viewer", runtime) - self.assertNotIn("a" * 48, runtime) - self.assertEqual( - json.loads((self.root / "secrets/mint-jwks").read_text(encoding="utf-8"))["keys"][0]["kid"], - "mint-key", - ) - for name in ( - "test-runtime-database-url", - "test-migration-database-url", - "runtime-database-url", - "migration-database-url", - "mint-jwks", - ): - self.assertEqual(os.stat(self.root / f"secrets/{name}").st_mode & 0o077, 0) - database_setup = (self.root / "database/initialize.sql").read_text( - encoding="utf-8" - ) - for schema in ( - "registry_internal", - "registry_data", - "registry_source", - "registry_derived", - "registry_context", - ): - self.assertIn(f"CREATE SCHEMA {schema}", database_setup) - - def test_render_runtime_selects_exact_package_and_listener(self) -> None: - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) - revision = "sha256:" + "2" * 64 - DEMO.render_runtime(self.root, revision) - runtime = (self.root / "runtime.yaml").read_text(encoding="utf-8") - self.assertIn(f"activeRevision: {revision}", runtime) - self.assertIn(f"root: {self.root.resolve() / 'build/package'}", runtime) - self.assertIn("bind: 127.0.0.1:18080", runtime) - - def test_token_lifetime_override_binds_mint_and_server_consistently(self) -> None: - DEMO.prepare( - self.root, - self.fixture, - 15432, - 18081, - 18080, - token_lifetime_seconds=900, - ) - mint = (self.root / "mint/mint.yaml").read_text(encoding="utf-8") - runtime_test = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn("lifetimeSeconds: 900", mint) - self.assertIn("maxTokenLifetimeSeconds: 900", runtime_test) - - revision = "sha256:" + "2" * 64 - DEMO.render_runtime(self.root, revision, token_lifetime_seconds=900) - runtime = (self.root / "runtime.yaml").read_text(encoding="utf-8") - self.assertIn("maxTokenLifetimeSeconds: 900", runtime) - - def test_token_lifetime_override_is_bounded(self) -> None: - with self.assertRaisesRegex(DEMO.DemoError, "between 60 and 900"): - DEMO.prepare( - self.root, - self.fixture, - 15432, - 18081, - 18080, - token_lifetime_seconds=59, - ) - with self.assertRaisesRegex(DEMO.DemoError, "between 60 and 900"): - DEMO.render_runtime( - self.root, - "sha256:" + "2" * 64, - token_lifetime_seconds=901, - ) - - def test_webhook_mode_extends_only_the_disposable_module_and_binds_its_compiled_digest( - self, - ) -> None: - webhook_key = self.root / "secrets/webhook-key" - webhook_key.write_bytes(b"k" * 32) - webhook_key.chmod(0o600) - fixture_module = self.fixture / "modules/business-establishment-summary/module.yaml" - original_fixture_module = fixture_module.read_bytes() - - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080, True, 18082) - - project_path = self.root / "project/registry.yaml" - project = project_path.read_text(encoding="utf-8") - module = ( - self.root / "project/modules/business-establishment-summary/module.yaml" - ).read_text(encoding="utf-8") - hook = DEMO.FIXTURE_CONFIGS["business-establishments"]["webhook"] - self.assertIn(hook["module_lock"], project) - self.assertNotIn(hook["module_lock"] + " digest:", project) - self.assertIn("id: operating-created-v1", module) - self.assertIn("afterEquals:\n operating-status: operating", module) - self.assertLess( - module.index("id: operating-created-v1"), - module.index(hook["entity_insertion"]), - ) - self.assertEqual(fixture_module.read_bytes(), original_fixture_module) - - digest = "sha256:" + "3" * 64 - report = self.root / "explain.json" - report.write_text( - json.dumps( - { - "explanation": { - "moduleClosure": [ - { - "id": hook["module_id"], - "version": "0.1.0", - "digest": digest, - } - ] - } - } - ), - encoding="utf-8", - ) - DEMO.bind_webhook_module(self.root, report) - self.assertIn(f" digest: {digest}", project_path.read_text(encoding="utf-8")) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn("origin: http://127.0.0.1:18082", runtime) - self.assertIn("networkProfile: loopbackDevelopmentHttp", runtime) - self.assertIn("dnsFamily: dualStackStrict", runtime) - self.assertIn("hmacSha256KeyRef: secret:file/webhook-key", runtime) - self.assertNotIn("k" * 32, runtime) - - def test_receiver_verifies_the_exact_cloudevents_and_signature_contract(self) -> None: - now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - event_id = str(uuid.UUID("00000000-0000-4000-8000-000000000001")) - body = json.dumps( - { - "entity": "establishment", - "packageRevision": "sha256:" + "4" * 64, - "recordId": "00000000-0000-4000-8000-000000000002", - "revision": 1, - "trigger": "created", - "values": { - "establishment-code": "ESTABLISHMENT-DEMO-001", - "operating-status": "operating", - }, - }, - sort_keys=True, - separators=(",", ":"), - ).encode() - headers = { - "accept": "application/json", - "content-type": "application/json", - "ce-specversion": "1.0", - "ce-id": event_id, - "ce-source": ( - "urn:registrystack:registry:business-establishments:" - f"instance:{DEMO.BUSINESS_INSTANCE_ID}" - ), - "ce-type": "operating-created-v1", - "ce-time": "2026-01-01T00:00:00Z", - "ce-dataschema": ( - "urn:breg:event-schema:business-establishments:establishment:" - "operating-created-v1:sha256:" + "5" * 64 - ), - "x-registry-event-generation": "1", - "x-registry-delivery-attempt": "1", - "x-registry-delivery-time": now, - "idempotency-key": "sha256:" + "6" * 64, - } - key = b"receiver-test-key" * 4 - headers["x-registry-signature"] = DEMO._expected_webhook_signature(key, headers, body) - - self.assertEqual( - DEMO._verify_webhook_request(key, "/events", headers, body), - (event_id, 1, 1, "sha256:" + "6" * 64), - ) - tampered = dict(headers) - tampered["idempotency-key"] = "sha256:" + "7" * 64 - with self.assertRaises(DEMO.DemoError): - DEMO._verify_webhook_request(key, "/events", tampered, body) - - def test_dead_letter_selection_returns_only_replay_eligible_value_free_metadata(self) -> None: - report = self.root / "list.json" - event_id = "00000000-0000-4000-8000-000000000001" - report.write_text( - json.dumps( - { - "deliveries": [ - { - "eventId": event_id, - "deliveryId": "establishment.operating-created-v1.webhook", - "generation": 1, - "state": "dead_lettered", - "replayEligible": True, - } - ] - } - ), - encoding="utf-8", - ) - - self.assertEqual( - DEMO.select_dead_letter(report), - (event_id, "establishment.operating-created-v1.webhook", 1), - ) - - def test_webhook_verification_covers_every_matching_seeded_establishment(self) -> None: - (self.root / "fixture-kind").write_text("business-establishments\n", encoding="ascii") - establishments, _, _ = DEMO.business_seed_spec() - events = {} - slot = 0 - for establishment in establishments: - if establishment["operatingStatus"] != "operating": - continue - slot += 1 - attempts = [{"generation": 1, "attempt": 1, "accepted": True}] - if slot == 2: - attempts = [ - {"generation": 1, "attempt": 1, "accepted": False}, - {"generation": 1, "attempt": 2, "accepted": True}, - ] - elif slot == 3: - attempts = [ - {"generation": 1, "attempt": 1, "accepted": False}, - {"generation": 2, "attempt": 1, "accepted": True}, - ] - events[f"event-{slot}"] = {"slot": slot, "attempts": attempts} - (self.root / "webhook-receiver-state.json").write_text( - json.dumps({"verificationFailures": 0, "events": events}), - encoding="utf-8", - ) - - DEMO.verify_webhook(self.root) - - events.pop(next(reversed(events))) - (self.root / "webhook-receiver-state.json").write_text( - json.dumps({"verificationFailures": 0, "events": events}), - encoding="utf-8", - ) - with self.assertRaisesRegex(DEMO.DemoError, "every matching seeded event"): - DEMO.verify_webhook(self.root) - - def test_schema_test_credentials_cover_every_packaged_journey_step(self) -> None: - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) - journey_source = (self.root / "project/tests/journeys.yaml").read_text( - encoding="utf-8" - ) - credential_source = (self.root / "schema-test-credentials.yaml").read_text( - encoding="utf-8" - ) - journey_id = next( - line.removeprefix(" - id: ").strip() - for line in journey_source.splitlines() - if line.startswith(" - id: ") - ) - expected = { - (journey_id, line.removeprefix(" - id: ").strip()) - for line in journey_source.splitlines() - if line.startswith(" - id: ") - } - actual = set( - re.findall( - r"journeyId: ([a-z0-9-]+), stepId: ([a-z0-9-]+)", - credential_source, - ) - ) - self.assertEqual(actual, expected) - - def test_business_seed_is_referentially_closed_and_stable(self) -> None: - establishments, businesses, assignments = DEMO.business_seed_spec() - establishment_codes = {item["establishmentCode"] for item in establishments} - business_codes = {business["businessCode"] for business in businesses} - self.assertEqual((len(establishments), len(businesses), len(assignments)), (8, 3, 8)) - self.assertEqual(len(establishment_codes), len(establishments)) - self.assertEqual(len(business_codes), len(businesses)) - self.assertTrue( - all( - "-" not in key - for rows in (establishments, businesses, assignments) - for row in rows - for key in row - ), - "seed data must use compiled public API field names", - ) - self.assertEqual( - [business["localRegistrationNumber"] for business in businesses], - [1001, 1002, 1003], - ) - self.assertTrue(all(row["establishmentCode"] in establishment_codes for row in assignments)) - self.assertTrue(all(row["businessCode"] in business_codes for row in assignments)) - self.assertEqual( - {item["operatingStatus"] for item in establishments}, - {"operating", "suspended"}, - ) - self.assertEqual( - sum(item["operatingStatus"] == "operating" for item in establishments), - 7, - ) - - def test_viewer_registration_is_created_only_after_a_business_id_is_known(self) -> None: - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) - business_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" - (self.root / "seed-record-ids.json").write_text( - json.dumps( - { - "establishments": {}, - "businesses": {"BUSINESS-DEMO-001": business_id}, - } - ), - encoding="utf-8", - ) - - DEMO.configure_viewer(self.root) - - viewer = (self.root / "mint/clients/business-demo-viewer.yaml").read_text( - encoding="utf-8" - ) - self.assertIn('scopes: ["registry:business:view"]', viewer) - self.assertIn(f'business_id: "{business_id}"', viewer) - self.assertIn('business_code: "BUSINESS-DEMO-001"', viewer) - self.assertIn('registry_purpose: "business-view"', viewer) - self.assertIn("viewer-key", viewer) - self.assertNotIn("signing-p256-private-jwk", viewer) - - def test_viewer_registration_refuses_a_non_uuid_bound_record(self) -> None: - DEMO.prepare(self.root, self.fixture, 15432, 18081, 18080) - (self.root / "seed-record-ids.json").write_text( - json.dumps({"businesses": {"BUSINESS-DEMO-001": "not-a-record-id"}}), - encoding="utf-8", - ) - - with self.assertRaisesRegex(DEMO.DemoError, "not a UUID"): - DEMO.configure_viewer(self.root) - - def test_bound_reads_require_profiled_record_envelopes(self) -> None: - household_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" - business_id = "0198f0f5-0877-7ae2-a853-09f2d47b6841" - DEMO._assert_bound_household( - profiled_record(household_id, {"householdCode": "HOUSEHOLD-DEMO-001"}), - household_id, - "HOUSEHOLD-DEMO-001", - ) - DEMO._assert_bound_business( - profiled_record(business_id, {"businessCode": "BUSINESS-DEMO-001"}), - business_id, - "BUSINESS-DEMO-001", - ) - - with self.assertRaisesRegex(DEMO.DemoError, "Registry Record metadata") as error: - DEMO._assert_bound_household( - { - "data": { - "recordIdentifier": "household-canary-must-not-appear", - "revisionIdentifier": "1", - "domainData": {"householdCode": "HOUSEHOLD-DEMO-001"}, - } - }, - household_id, - "HOUSEHOLD-DEMO-001", - ) - self.assertNotIn("household-canary-must-not-appear", str(error.exception)) - - def test_viewer_queries_prove_bound_get_claim_lookup_and_concealed_denials(self) -> None: - business_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" - (self.root / "seed-record-ids.json").write_text( - json.dumps({"businesses": {"BUSINESS-DEMO-001": business_id}}), - encoding="utf-8", - ) - calls: list[tuple[str, str, str, object, object]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - self.assertEqual(root, self.root.resolve()) - calls.append((method, path, token_name, body, expected)) - if expected == 404: - return {"code": "resource.not_found"}, {} - return profiled_record(business_id, {"businessCode": "BUSINESS-DEMO-001"}), {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch.object( - DEMO, "_print_query" - ): - DEMO.query_business(self.root, "viewer") - - self.assertEqual(len(calls), 4) - self.assertEqual(calls[0][0:3], ("GET", f"/v1/records/businesses/{business_id}?accessProfile=business-viewer", "viewer-token")) - self.assertEqual(calls[1][0:3], ("POST", "/v1/records/businesses:lookup?accessProfile=business-viewer", "viewer-token")) - self.assertEqual(calls[1][3], {"selector": "by-business-code"}) - self.assertTrue(all(call[2] == "viewer-token" for call in calls)) - self.assertEqual([call[4] for call in calls], [200, 200, 404, 404]) - - def test_operator_selector_query_uses_the_exact_values_property(self) -> None: - business_id = "0198f0f5-0877-7ae2-a853-09f2d47b6840" - (self.root / "seed-record-ids.json").write_text( - json.dumps({"businesses": {"BUSINESS-DEMO-001": business_id}}), - encoding="utf-8", - ) - calls: list[tuple[str, str, object]] = [] - expected_get_rows = [ - [ - {"establishmentCode": "ESTABLISHMENT-DEMO-001", "siteName": "North Quay Head Office", "establishmentKind": "office", "operatingStatus": "operating"}, - {"establishmentCode": "ESTABLISHMENT-DEMO-002", "siteName": "North Quay Riverside Works", "establishmentKind": "production", "operatingStatus": "operating"}, - ], - [{"businessCode": "BUSINESS-DEMO-001", "administrativeArea": "north-demo", "localRegistrationNumber": 1001, "branchCount": 1}], - [{"businessCode": "BUSINESS-DEMO-001", "productionSiteCount": 1, "suspendedSiteCount": 0, "hasProductionSite": True}], - [{"businessCode": "BUSINESS-DEMO-002", "hasProductionSite": True, "branchCount": 1, "suspendedSiteCount": 1}], - ] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, body)) - if method == "POST": - return profiled_record(business_id, {"businessCode": "BUSINESS-DEMO-001"}), {} - rows = expected_get_rows[len([call for call in calls if call[0] == "GET"]) - 1] - response = profiled_collection(rows) - response["count"] = len(rows) - return response, {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch.object( - DEMO, "_print_query" - ): - DEMO.query_business(self.root, "operator") - - query_paths = [call[1] for call in calls[:-1]] - self.assertIn("$select=establishmentCode,siteName,establishmentKind,operatingStatus", query_paths[0]) - self.assertIn("$orderby=establishmentCode", query_paths[0]) - self.assertIn("$filter=administrativeArea%20eq", query_paths[1]) - self.assertIn("$orderby=localRegistrationNumber", query_paths[1]) - self.assertIn("$filter=hasProductionSite%20eq", query_paths[2]) - self.assertIn("suspendedSiteCount%20eq", query_paths[2]) - self.assertIn("$filter=hasProductionSite%20eq", query_paths[3]) - self.assertTrue( - all( - internal_name not in path - for path in query_paths - for internal_name in ( - "establishment-code", - "administrative-area", - "local-registration-number", - "production-site-count", - "suspended-site-count", - ) - ) - ) - self.assertEqual(calls[-1][0:2], ("POST", "/v1/records/businesses:lookup?accessProfile=business-operator")) - self.assertEqual( - calls[-1][2], - { - "selector": "by-local-reference", - "values": { - "administrativeArea": "north-demo", - "localRegistrationNumber": 1001, - }, - }, - ) - - def test_prepare_refuses_a_fixture_without_the_expected_localization_boundary(self) -> None: - bad_fixture = Path(self.temporary.name) / "bad-fixture" - bad_fixture.mkdir() - (bad_fixture / "registry.yaml").write_text("apiVersion: wrong\n", encoding="utf-8") - with self.assertRaisesRegex(DEMO.DemoError, "expected package line"): - DEMO.prepare(self.root, bad_fixture, 15432, 18081, 18080) - - def test_prepare_household_remains_explicit_legacy_demo_fixture(self) -> None: - household_fixture = MODULE_PATH.parents[2] / "acceptance/publicschema-household" - - DEMO.prepare( - self.root, - household_fixture, - 15432, - 18081, - 18080, - fixture_kind="household", - ) - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn(f"instanceId: {DEMO.INSTANCE_ID}", project) - self.assertNotIn("publicschema-household-acceptance", project) - self.assertIn("audience: urn:breg:household-demo", runtime) - self.assertIn("allowedClients: [household-demo, household-demo-no-purpose, household-demo-viewer]", runtime) - self.assertTrue((self.root / "mint/clients/household-demo.yaml").is_file()) - self.assertTrue((self.root / "mint/clients/household-demo-no-purpose.yaml").is_file()) - - def test_prepare_asset_site_writes_distinct_clients_credentials_and_local_project(self) -> None: - for name in ("planner", "planner-no-purpose"): - (self.root / f"keys/{name}-public.jwk.json").write_text( - json.dumps(public_jwk(f"{name}-key")), encoding="utf-8" - ) - asset_fixture = MODULE_PATH.parents[2] / "acceptance/asset-site-placement" - - DEMO.prepare(self.root, asset_fixture, 15432, 18081, 18080, fixture_kind="asset-site") - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - self.assertIn("environment: local", project) - self.assertIn(f"instanceId: {DEMO.ASSET_SITE_INSTANCE_ID}", project) - self.assertIn(f"sourceRevision: {DEMO.ASSET_SITE_SOURCE_REVISION}", project) - self.assertNotIn("asset-site-placement-acceptance", project) - self.assertIn("requiredScopes: [registry:asset:operate]", project) - self.assertIn("requiredScopes: [registry:asset:plan]", project) - journeys = (self.root / "project/tests/journeys.yaml").read_text(encoding="utf-8") - self.assertIn("scopes: [registry:asset:operate]", journeys) - self.assertEqual(journeys.count("scopes: [registry:asset:plan]"), 2) - self.assertIn( - " - id: planner-without-purpose-is-concealed\n" - " entity: asset-item\n" - " accessProfile: site-planner\n" - " claims:\n" - " principal: synthetic-site-planner\n" - " scopes: [registry:asset:plan]\n" - " request:\n" - " operation: get\n" - " recordRef: renamed-asset\n", - journeys, - ) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn(f"audience: {DEMO.ASSET_SITE_AUDIENCE}", runtime) - self.assertIn("allowedClients: [asset-site-demo-operator, asset-site-demo-planner, asset-site-demo-planner-no-purpose]", runtime) - self.assertIn(f"instanceId: {DEMO.ASSET_SITE_INSTANCE_ID}", runtime) - - operator = (self.root / "mint/clients/asset-site-demo-operator.yaml").read_text(encoding="utf-8") - planner = (self.root / "mint/clients/asset-site-demo-planner.yaml").read_text(encoding="utf-8") - no_purpose = (self.root / "mint/clients/asset-site-demo-planner-no-purpose.yaml").read_text(encoding="utf-8") - self.assertIn('registry_principal: "synthetic-asset-operator"', operator) - self.assertIn('scopes: ["registry:asset:operate"]', operator) - self.assertIn('registry_purpose: "asset-management"', operator) - self.assertIn('registry_principal: "synthetic-site-planner"', planner) - self.assertIn('scopes: ["registry:asset:plan"]', planner) - self.assertIn('registry_purpose: "site-planning"', planner) - self.assertIn('registry_principal: "synthetic-site-planner"', no_purpose) - self.assertIn('scopes: ["registry:asset:plan"]', no_purpose) - self.assertNotIn("registry_purpose", no_purpose) - - credentials = (self.root / "schema-test-credentials.yaml").read_text(encoding="utf-8") - self.assertIn("stepId: planner-without-purpose-is-concealed", credentials) - self.assertIn("tokenRef: secret:file/planner-no-purpose-token", credentials) - - def test_prepare_facility_writes_row_bound_clients_and_schema_credentials(self) -> None: - (self.root / "keys/south-operator-public.jwk.json").write_text( - json.dumps(public_jwk("south-operator-key")), encoding="utf-8" - ) - facility_fixture = MODULE_PATH.parents[2] / "acceptance/facility" - - DEMO.prepare(self.root, facility_fixture, 15432, 18081, 18080, fixture_kind="facility") - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - self.assertIn("environment: local", project) - self.assertIn(f"instanceId: {DEMO.FACILITY_INSTANCE_ID}", project) - self.assertIn(f"sourceRevision: {DEMO.FACILITY_SOURCE_REVISION}", project) - self.assertNotIn("facility-acceptance", project) - self.assertIn("requiredScopes: [registry:facility:operate]", project) - self.assertEqual(project.count("claim: administrative_boundaries"), 5) - self.assertEqual(project.count("operator: equals"), 5) - self.assertNotIn("operator: in", project) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn(f"audience: {DEMO.FACILITY_AUDIENCE}", runtime) - self.assertIn("allowedClients: [facility-demo-operator, facility-demo-south-operator]", runtime) - - north = (self.root / "mint/clients/facility-demo-operator.yaml").read_text(encoding="utf-8") - south = (self.root / "mint/clients/facility-demo-south-operator.yaml").read_text(encoding="utf-8") - self.assertIn('scopes: ["registry:facility:operate"]', north) - self.assertIn('registry_purpose: "facility-registry"', north) - self.assertIn('administrative_boundaries: "north-district"', north) - self.assertIn('administrative_boundaries: "south-district"', south) - - credentials = (self.root / "schema-test-credentials.yaml").read_text(encoding="utf-8") - self.assertIn("stepId: south-district-claim-cannot-see-north-record", credentials) - self.assertIn("tokenRef: secret:file/south-operator-token", credentials) - journeys = (self.root / "project/tests/journeys.yaml").read_text(encoding="utf-8") - self.assertEqual(journeys.count("scopes: [registry:facility:operate]"), 2) - - def test_prepare_asset_change_request_writes_exact_lifecycle_clients_and_credentials(self) -> None: - for name in ("planner", "submitter", "reviewer", "supervisor", "applier"): - (self.root / f"keys/{name}-public.jwk.json").write_text( - json.dumps(public_jwk(f"{name}-key")), encoding="utf-8" - ) - fixture = MODULE_PATH.parents[2] / "acceptance/asset-site-placement-change-requests" - - DEMO.prepare( - self.root, - fixture, - 15432, - 18081, - 18080, - fixture_kind="asset-change-request", - ) - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - self.assertIn("environment: local", project) - self.assertIn(f"instanceId: {DEMO.ASSET_CHANGE_INSTANCE_ID}", project) - self.assertIn(f"sourceRevision: {DEMO.ASSET_CHANGE_SOURCE_REVISION}", project) - self.assertNotIn("asset-site-placement-change-requests-acceptance", project) - self.assertIn("requiredScopes: [registry:asset:operate]", project) - self.assertIn("requiredScopes: [registry:asset:plan]", project) - journeys = (self.root / "project/tests/journeys.yaml").read_text(encoding="utf-8") - self.assertIn("scopes: [registry:asset:operate]", journeys) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn(f"audience: {DEMO.ASSET_CHANGE_AUDIENCE}", runtime) - self.assertIn( - "allowedClients: [asset-change-demo-operator, asset-change-demo-planner, asset-change-demo-submitter, asset-change-demo-reviewer, asset-change-demo-supervisor, asset-change-demo-applier]", - runtime, - ) - expected = { - "planner": ("registry:asset:plan", "site-planning"), - "submitter": ("registry:corrections:submit", "asset-correction"), - "reviewer": ("registry:corrections:review", "asset-correction-review"), - "supervisor": ("registry:corrections:supervise", "asset-correction-review"), - "applier": ("registry:corrections:apply", "asset-correction-apply"), - } - for persona, (scope, purpose) in expected.items(): - client = ( - self.root / f"mint/clients/asset-change-demo-{persona}.yaml" - ).read_text(encoding="utf-8") - self.assertIn(f'scopes: ["{scope}"]', client) - expected_principal = ( - "synthetic-site-planner" if persona == "planner" else f"correction-{persona}" - ) - self.assertIn(f'registry_principal: "{expected_principal}"', client) - self.assertIn(f'registry_purpose: "{purpose}"', client) - operator = ( - self.root / "mint/clients/asset-change-demo-operator.yaml" - ).read_text(encoding="utf-8") - self.assertIn('scopes: ["registry:asset:operate"]', operator) - - credentials = (self.root / "schema-test-credentials.yaml").read_text( - encoding="utf-8" - ) - self.assertEqual(credentials.count("journeyId: placement-correction-request-flow"), 18) - for token_name in ( - "operator-token", - "submitter-token", - "reviewer-token", - "supervisor-token", - "applier-token", - ): - self.assertIn(f"tokenRef: secret:file/{token_name}", credentials) - - def test_prepare_inspection_writes_inspector_clients_and_schema_credentials(self) -> None: - inspection_fixture = MODULE_PATH.parents[2] / "acceptance/inspection" - - DEMO.prepare(self.root, inspection_fixture, 15432, 18081, 18080, fixture_kind="inspection") - - project = (self.root / "project/registry.yaml").read_text(encoding="utf-8") - self.assertIn("environment: local", project) - self.assertIn(f"instanceId: {DEMO.INSPECTION_INSTANCE_ID}", project) - self.assertIn(f"sourceRevision: {DEMO.INSPECTION_SOURCE_REVISION}", project) - self.assertNotIn("inspection-acceptance", project) - self.assertIn("requiredScopes: [registry:inspection:inspect]", project) - - runtime = (self.root / "runtime-test.yaml").read_text(encoding="utf-8") - self.assertIn(f"audience: {DEMO.INSPECTION_AUDIENCE}", runtime) - self.assertIn("allowedClients: [inspection-demo-inspector, inspection-demo-no-purpose]", runtime) - - inspector = (self.root / "mint/clients/inspection-demo-inspector.yaml").read_text( - encoding="utf-8" - ) - no_purpose = (self.root / "mint/clients/inspection-demo-no-purpose.yaml").read_text( - encoding="utf-8" - ) - self.assertIn('scopes: ["registry:inspection:inspect"]', inspector) - self.assertIn('registry_principal: "synthetic-inspection-inspector"', inspector) - self.assertIn('registry_purpose: "facility-inspection"', inspector) - self.assertIn('registry_principal: "synthetic-inspection-inspector"', no_purpose) - self.assertNotIn("registry_purpose", no_purpose) - - credentials = (self.root / "schema-test-credentials.yaml").read_text(encoding="utf-8") - self.assertIn("stepId: inspector-without-purpose-is-concealed", credentials) - self.assertIn("tokenRef: secret:file/no-purpose-token", credentials) - journeys = (self.root / "project/tests/journeys.yaml").read_text(encoding="utf-8") - self.assertEqual(journeys.count("scopes: [registry:inspection:inspect]"), 2) - - def test_seed_asset_site_uses_normal_api_routes_and_validates_planner_projection(self) -> None: - created_ids = { - "/v1/records/assets": str(uuid.uuid4()), - "/v1/records/sites": str(uuid.uuid4()), - "/v1/records/placements": str(uuid.uuid4()), - "/v1/records/inspections": str(uuid.uuid4()), - } - calls: list[tuple[str, str, str, dict[str, object] | None]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, token_name, body)) - route = path.split("?", 1)[0] - if method == "POST": - return profiled_record( - created_ids[route], body.get("data", {}) if body else {} - ), {} - if token_name == "planner-no-purpose-token": - return {"code": "resource.not_found"}, {} - return profiled_collection( - [{"assetCode": "ASSET-SYNTH-001", "label": "Synthetic water pump"}] - ), {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch("builtins.print"): - DEMO.seed_asset_site(self.root) - - post_paths = [call[1] for call in calls if call[0] == "POST"] - self.assertEqual( - post_paths, - [ - "/v1/records/assets?accessProfile=asset-operator", - "/v1/records/sites?accessProfile=asset-operator", - "/v1/records/placements?accessProfile=asset-operator", - "/v1/records/inspections?accessProfile=asset-operator", - ], - ) - self.assertTrue((self.root / "seed-record-ids.json").is_file()) - rendered = json.dumps([call[3] for call in calls if call[0] == "POST"], sort_keys=True) - self.assertIn("observedAt", rendered) - self.assertIn("validFrom", rendered) - - def test_seed_facility_uses_normal_api_routes_and_row_boundary_claims(self) -> None: - created_ids = { - "/v1/records/facilities": [str(uuid.uuid4()), str(uuid.uuid4())], - "/v1/records/permits": [str(uuid.uuid4()), str(uuid.uuid4())], - "/v1/records/installations": [str(uuid.uuid4())], - "/v1/records/discharge-reports": [str(uuid.uuid4()), str(uuid.uuid4())], - } - calls: list[tuple[str, str, str, dict[str, object] | None]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, token_name, body)) - route = path.split("?", 1)[0] - if method == "POST": - return profiled_record( - created_ids[route].pop(0), body.get("data", {}) if body else {} - ), {} - if expected == 404: - return {"code": "resource.not_found"}, {} - return profiled_collection([{"facilityCode": "FACILITY-SYNTH-001"}]), {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch("builtins.print"): - DEMO.seed_facility(self.root) - - post_calls = [call for call in calls if call[0] == "POST"] - self.assertEqual( - [call[1] for call in post_calls], - [ - "/v1/records/facilities?accessProfile=facility-operator", - "/v1/records/facilities?accessProfile=facility-operator", - "/v1/records/permits?accessProfile=facility-operator", - "/v1/records/permits?accessProfile=facility-operator", - "/v1/records/installations?accessProfile=facility-operator", - "/v1/records/discharge-reports?accessProfile=facility-operator", - "/v1/records/discharge-reports?accessProfile=facility-operator", - ], - ) - self.assertEqual(post_calls[0][2], "operator-token") - self.assertEqual(post_calls[1][2], "south-operator-token") - rendered = json.dumps([call[3] for call in post_calls], sort_keys=True) - self.assertIn('"administrativeBoundary": "north-district"', rendered) - self.assertIn('"centroid"', rendered) - self.assertIn('"areaValue": "1.2500"', rendered) - self.assertTrue((self.root / "seed-record-ids.json").is_file()) - - def test_seed_asset_change_request_leaves_one_actor_bound_draft(self) -> None: - created_ids = { - "/v1/records/assets": [str(uuid.uuid4())], - "/v1/records/sites": [str(uuid.uuid4()), str(uuid.uuid4())], - "/v1/records/placements": [str(uuid.uuid4())], - "/v1/records/placement-correction-requests": [str(uuid.uuid4())], - } - calls: list[tuple[str, str, str, dict[str, object] | None]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, token_name, body)) - route = path.split("?", 1)[0] - if method == "POST": - return profiled_record( - created_ids[route].pop(0), body.get("data", {}) if body else {} - ), {} - response = profiled_record( - json.loads((self.root / "seed-record-ids.json").read_text(encoding="utf-8"))[ - "changeRequests" - ]["PLACEMENT-CORRECTION"], - {"reason": "Site correction after supervisor field audit"}, - ) - response["data"]["request"] = { - "bregState": "draft", - "actions": [{"operation": "submit_request"}], - } - return response, {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch( - "builtins.print" - ): - DEMO.seed_asset_change_request(self.root) - - post_calls = [call for call in calls if call[0] == "POST"] - self.assertEqual( - [call[1] for call in post_calls], - [ - "/v1/records/assets?accessProfile=asset-operator", - "/v1/records/sites?accessProfile=asset-operator", - "/v1/records/sites?accessProfile=asset-operator", - "/v1/records/placements?accessProfile=asset-operator", - "/v1/records/placement-correction-requests?accessProfile=correction-submitter", - ], - ) - self.assertEqual([call[2] for call in post_calls[:4]], ["operator-token"] * 4) - self.assertEqual(post_calls[4][2], "submitter-token") - self.assertEqual(calls[-1][0:3], ( - "GET", - calls[-1][1], - "submitter-token", - )) - self.assertIn("accessProfile=correction-submitter", calls[-1][1]) - seed_ids = json.loads( - (self.root / "seed-record-ids.json").read_text(encoding="utf-8") - ) - self.assertIn("PLACEMENT-CORRECTION", seed_ids["changeRequests"]) - - def test_asset_change_request_planner_lists_context_without_operator_only_fields(self) -> None: - request_id = str(uuid.uuid4()) - (self.root / "seed-record-ids.json").write_text( - json.dumps({"changeRequests": {"PLACEMENT-CORRECTION": request_id}}), - encoding="utf-8", - ) - calls: list[tuple[str, str, str]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, token_name)) - return profiled_collection([{"label": "visible"}]), {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch( - "builtins.print" - ): - DEMO.query_asset_change_request(self.root, "planner") - - self.assertEqual( - calls, - [ - ( - "GET", - "/v1/records/assets?accessProfile=site-planner&$top=20", - "planner-token", - ), - ( - "GET", - "/v1/records/sites?accessProfile=site-planner&$top=20", - "planner-token", - ), - ( - "GET", - "/v1/records/placements?accessProfile=site-planner&$top=20", - "planner-token", - ), - ], - ) - - def test_seed_inspection_uses_normal_api_routes_and_create_only_records(self) -> None: - created_ids = { - "/v1/records/authorities": [str(uuid.uuid4())], - "/v1/records/inspections": [str(uuid.uuid4())], - "/v1/records/inspection-observations": [str(uuid.uuid4())], - "/v1/records/permits": [str(uuid.uuid4()), str(uuid.uuid4())], - } - calls: list[tuple[str, str, str, dict[str, object] | None]] = [] - - def request( - root: Path, - method: str, - path: str, - token_name: str, - body: dict[str, object] | None = None, - idempotency_key: str | None = None, - expected: int = 200, - ) -> tuple[dict[str, object], dict[str, str]]: - calls.append((method, path, token_name, body)) - route = path.split("?", 1)[0] - if method == "POST": - return profiled_record( - created_ids[route].pop(0), body.get("data", {}) if body else {} - ), {} - if expected == 404: - return {"code": "resource.not_found"}, {} - expected_count = 2 if route == "/v1/records/permits" else 1 - return profiled_collection([{} for _ in range(expected_count)]), {} - - with mock.patch.object(DEMO, "_request", side_effect=request), mock.patch("builtins.print"): - DEMO.seed_inspection(self.root) - - post_paths = [call[1] for call in calls if call[0] == "POST"] - self.assertEqual( - post_paths, - [ - "/v1/records/authorities?accessProfile=inspection-inspector", - "/v1/records/inspections?accessProfile=inspection-inspector", - "/v1/records/inspection-observations?accessProfile=inspection-inspector", - "/v1/records/permits?accessProfile=inspection-inspector", - "/v1/records/permits?accessProfile=inspection-inspector", - ], - ) - rendered = json.dumps([call[3] for call in calls if call[0] == "POST"], sort_keys=True) - self.assertIn("observationSchemaMetadata", rendered) - self.assertIn("correctedPermit", rendered) - self.assertIn("INSPECTION-SYNTH-001", rendered) - self.assertTrue((self.root / "seed-record-ids.json").is_file()) - - def test_handoff_contains_only_frozen_persona_metadata_and_owner_only_token_paths(self) -> None: - (self.root / "breg-origin").write_text("http://127.0.0.1:18080\n", encoding="ascii") - expires = 1798761600 - for name in ("operator-token", "viewer-token"): - path = self.root / f"secrets/{name}" - path.write_text(compact_jwt(expires), encoding="ascii") - path.chmod(0o600) - - handoff = self.root / "handoff.json" - DEMO.write_handoff(self.root, "business-establishments", handoff) - - self.assertEqual(handoff.stat().st_mode & 0o077, 0) - value = json.loads(handoff.read_text(encoding="utf-8")) - self.assertEqual(value["schemaVersion"], "registry-workspace/demo/v1") - self.assertEqual(value["registry"], {"id": "business-establishments", "baseUrl": "http://127.0.0.1:18080"}) - self.assertEqual( - value["personas"], - [ - { - "id": "business-operator", - "label": "Business operator", - "tokenFile": str((self.root / "secrets/operator-token").resolve()), - "accessProfile": "business-operator", - "expiresAt": "2027-01-01T00:00:00Z", - }, - { - "id": "business-viewer", - "label": "Business viewer", - "tokenFile": str((self.root / "secrets/viewer-token").resolve()), - "accessProfile": "business-viewer", - "expiresAt": "2027-01-01T00:00:00Z", - }, - ], - ) - rendered = json.dumps(value, sort_keys=True) - self.assertNotIn(compact_jwt(expires), rendered) - - def test_handoff_refuses_group_readable_token_files(self) -> None: - (self.root / "breg-origin").write_text("http://127.0.0.1:18080\n", encoding="ascii") - for name in ("operator-token", "viewer-token"): - path = self.root / f"secrets/{name}" - path.write_text(compact_jwt(1798752000), encoding="ascii") - path.chmod(0o600) - (self.root / "secrets/viewer-token").chmod(0o640) - - with self.assertRaisesRegex(DEMO.DemoError, "owner-only"): - DEMO.write_handoff(self.root, "business-establishments", self.root / "handoff.json") - - def test_handoff_for_new_fixtures_contains_only_frontend_personas(self) -> None: - (self.root / "breg-origin").write_text("http://127.0.0.1:18080\n", encoding="ascii") - (self.root / "secrets/operator-token").write_text(compact_jwt(1798761600), encoding="ascii") - (self.root / "secrets/operator-token").chmod(0o600) - - for fixture_kind, expected in ( - ("facility", "facility-operator"), - ("inspection", "inspection-inspector"), - ): - handoff = self.root / f"{fixture_kind}-handoff.json" - DEMO.write_handoff(self.root, fixture_kind, handoff) - value = json.loads(handoff.read_text(encoding="utf-8")) - self.assertEqual([persona["id"] for persona in value["personas"]], [expected]) - self.assertNotIn("south-operator-token", json.dumps(value, sort_keys=True)) - self.assertNotIn("no-purpose-token", json.dumps(value, sort_keys=True)) - - def test_change_request_handoff_carries_only_an_inert_record_path_and_persona_order(self) -> None: - (self.root / "breg-origin").write_text( - "http://127.0.0.1:18080\n", encoding="ascii" - ) - request_id = str(uuid.uuid4()) - (self.root / "seed-record-ids.json").write_text( - json.dumps( - {"changeRequests": {"PLACEMENT-CORRECTION": request_id}}, - sort_keys=True, - ), - encoding="utf-8", - ) - for name in ("planner", "submitter", "reviewer", "supervisor", "applier"): - path = self.root / f"secrets/{name}-token" - path.write_text(compact_jwt(1798761600), encoding="ascii") - path.chmod(0o600) - - handoff = self.root / "asset-change-request-handoff.json" - DEMO.write_handoff(self.root, "asset-change-request", handoff) - - value = json.loads(handoff.read_text(encoding="utf-8")) - self.assertEqual( - [persona["id"] for persona in value["personas"]], - [ - "correction-submitter", - "correction-reviewer", - "correction-supervisor", - "correction-applier", - "site-planner", - ], - ) - self.assertEqual( - value["walkthrough"], - { - "kind": "asset-placement-correction", - "entryPath": f"/records/placement-correction-request/{request_id}", - "personaOrder": [ - "correction-submitter", - "correction-reviewer", - "correction-supervisor", - "correction-applier", - ], - }, - ) - rendered = json.dumps(value, sort_keys=True) - self.assertNotIn(compact_jwt(1798761600), rendered) - self.assertNotIn("operator-token", rendered) - - def test_demo_root_must_not_be_a_symbolic_link(self) -> None: - linked_root = Path(self.temporary.name) / "linked-run" - linked_root.symlink_to(self.root, target_is_directory=True) - with self.assertRaisesRegex(DEMO.DemoError, "must not be a symbolic link"): - DEMO.prepare(linked_root, self.fixture, 15432, 18081, 18080) - - def test_token_capture_removes_transport_newline_and_uses_owner_only_mode(self) -> None: - output = self.root / "secrets/token" - DEMO.store_token(output, b"aaa.bbb.ccc\n") - - self.assertEqual("aaa.bbb.ccc", output.read_text(encoding="ascii")) - self.assertEqual(0, output.stat().st_mode & 0o077) +REPO = HERE.parents[3] + +class DevPreparationTests(unittest.TestCase): + def prepare(self, fixture: str): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + root = Path(temporary.name) + root.mkdir(mode=0o700, exist_ok=True) + sources = {"household":"publicschema-household", "asset-site":"asset-site-placement", "asset-change-request":"asset-site-placement-change-requests"} + DEMO.prepare_dev(root, REPO / "products/breg/acceptance" / sources.get(fixture, fixture), fixture) + return root, json.loads((root / "project/dev-clients.yaml").read_text()) + + def test_business_clients_use_private_key_dev_contract(self): + root, document = self.prepare("business-establishments") + self.assertEqual([c["id"] for c in document["clients"]], ["business-demo", "business-demo-viewer"]) + viewer = document["clients"][1] + self.assertEqual(viewer["claims"]["business_code"], "BUSINESS-DEMO-001") + registry = (root / "project/registry.yaml").read_text() + self.assertIn("field: business-code\n claim: business_code", registry) + self.assertNotIn("operator-without-purpose-is-concealed", (root / "project/tests/journeys.yaml").read_text()) + + def test_each_fixture_has_one_explicit_client_per_persona_profile(self): + expected = {"household": 2, "asset-site": 2, "asset-change-request": 6, "facility": 1, "inspection": 1} + for fixture, count in expected.items(): + with self.subTest(fixture=fixture): + _, document = self.prepare(fixture) + self.assertEqual(len(document["clients"]), count) + profiles = [c["accessProfiles"][0] for c in document["clients"]] + self.assertEqual(len(profiles), len(set(profiles))) + self.assertTrue(all(c["scopes"] and c["claims"] for c in document["clients"])) + + def test_prepare_refuses_existing_output(self): + root, _ = self.prepare("business-establishments") with self.assertRaises(FileExistsError): - DEMO.store_token(output, b"ddd.eee.fff\n") - - def test_token_capture_refuses_non_compact_output(self) -> None: - for value in (b"not a token\n", b" aaa.bbb.ccc\n"): - with self.subTest(value=value): - with self.assertRaisesRegex(DEMO.DemoError, "compact JWT"): - DEMO.store_token(self.root / "secrets/token", value) - - -class WaitHttpTests(unittest.TestCase): - def test_readiness_timeout_reports_the_last_connection_error(self) -> None: - import socket - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: - listener.bind(("127.0.0.1", 0)) - port = listener.getsockname()[1] - with self.assertRaises(DEMO.DemoError) as raised: - DEMO.wait_http(f"http://127.0.0.1:{port}/health", 0.3) - message = str(raised.exception) - self.assertIn("did not become ready within 0.3 seconds", message) - self.assertIn("Connection refused", message) - - -class WebhookAcceptanceTests(unittest.TestCase): - def setUp(self) -> None: - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name) - (self.root / "secrets").mkdir(mode=0o700) - self.key = b"synthetic-receiver-test-key" * 2 - DEMO._write_new(self.root / "secrets/webhook-key", self.key.decode()) - self.process = None - self.addCleanup(self.stop_receiver) - self.start_receiver() - - def start_receiver(self) -> None: - with socket.socket() as listener: - listener.bind(("127.0.0.1", 0)) - port = listener.getsockname()[1] - self.origin = f"http://127.0.0.1:{port}" - (self.root / "receiver-origin").write_text(self.origin) - self.process = subprocess.Popen( - [sys.executable, str(MODULE_PATH), "serve-webhook-receiver", "--root", str(self.root)], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - DEMO.wait_http(self.origin + "/ready", 3) - - def stop_receiver(self) -> None: - if self.process is not None: - self.process.kill() - self.process.wait(timeout=3) - self.process = None - - def request( - self, - *, - generation: int = 1, - attempt: int = 1, - code: str = "SYNTHETIC", - valid: bool = True, - event_time: str | None = None, - event_id: str = "00000000-0000-4000-8000-000000000001", - delivery_key: str | None = None, - ) -> int: - body = json.dumps( - { - "entity": "establishment", - "packageRevision": "sha256:" + "4" * 64, - "recordId": "00000000-0000-4000-8000-000000000002", - "revision": 1, - "trigger": "created", - "values": {"establishment-code": code, "operating-status": "operating"}, - }, - sort_keys=True, - separators=(",", ":"), - ).encode() - headers = { - "accept": "application/json", - "content-type": "application/json", - "ce-specversion": "1.0", - "ce-id": event_id, - "ce-source": ( - "urn:registrystack:registry:business-establishments:" - f"instance:{DEMO.BUSINESS_INSTANCE_ID}" - ), - "ce-type": "operating-created-v1", - "ce-time": event_time or "2026-01-01T00:00:00Z", - "ce-dataschema": ( - "urn:breg:event-schema:business-establishments:establishment:" - "operating-created-v1:sha256:" + "5" * 64 - ), - "x-registry-event-generation": str(generation), - "x-registry-delivery-attempt": str(attempt), - "x-registry-delivery-time": datetime.now(timezone.utc).isoformat(), - "idempotency-key": delivery_key or "sha256:" + str(generation) * 64, - } - headers["x-registry-signature"] = DEMO._expected_webhook_signature(self.key, headers, body) - if not valid: - headers["x-registry-signature"] = "v1=invalid" - request = urllib.request.Request(self.origin + "/events", body, headers, method="POST") - try: - with urllib.request.urlopen(request, timeout=3) as response: - return response.status - except urllib.error.HTTPError as error: - error.close() - return error.code - - def accepted(self) -> list[tuple[str, str, bytes]]: - connection = sqlite3.connect(self.root / "webhook-inbox.sqlite3") - try: - return connection.execute( - "SELECT event_id, metadata, body FROM accepted_events ORDER BY source, event_id" - ).fetchall() - finally: - connection.close() - - def test_authenticated_retry_and_process_restart_preserve_one_durable_acceptance(self) -> None: - self.assertEqual(self.request(), 204) - retained = self.accepted() - self.assertEqual(len(retained), 1) - self.assertEqual(json.loads(retained[0][2])["values"]["establishment-code"], "SYNTHETIC") - self.assertEqual(self.request(attempt=2), 204) - self.stop_receiver() - self.start_receiver() - self.assertEqual(self.request(attempt=3), 204) - self.assertEqual(self.accepted(), retained) - state = DEMO._read_json_object(self.root / "webhook-receiver-state.json") - self.assertEqual(len(next(iter(state["events"].values()))["attempts"]), 3) - self.assertNotIn("SYNTHETIC", json.dumps(state)) - self.assertEqual((self.root / "webhook-inbox.sqlite3").stat().st_mode & 0o077, 0) - - def test_replay_generation_binds_its_new_delivery_key_without_duplicate_work(self) -> None: - self.assertEqual(self.request(), 204) - retained = self.accepted() - self.stop_receiver() - self.start_receiver() - self.assertEqual(self.request(generation=2), 204) - self.assertEqual(self.request(generation=2, attempt=2), 204) - self.assertEqual(self.accepted(), retained) - connection = sqlite3.connect(self.root / "webhook-inbox.sqlite3") - try: - self.assertEqual(connection.execute("SELECT COUNT(*) FROM accepted_deliveries").fetchone(), (2,)) - finally: - connection.close() - - def test_previously_unaccepted_replay_creates_one_work_item(self) -> None: - self.assertEqual(self.request(generation=2), 204) - self.assertEqual(len(self.accepted()), 1) - - def test_reused_delivery_key_cannot_bind_another_event_or_generation(self) -> None: - self.assertEqual(self.request(), 204) - retained = self.accepted() - self.assertEqual(self.request(generation=2, delivery_key="sha256:" + "1" * 64), 409) - self.assertEqual( - self.request(event_id="00000000-0000-4000-8000-000000000003", attempt=2), - 409, - ) - self.assertEqual(self.accepted(), retained) - - def test_conflicting_authenticated_duplicate_preserves_original_acceptance(self) -> None: - self.assertEqual(self.request(), 204) - retained = self.accepted() - self.assertEqual(self.request(code="CHANGED"), 409) - self.assertEqual(self.request(event_time="2026-01-02T00:00:00Z"), 409) - self.assertEqual(self.request(generation=2, code="CHANGED"), 409) - self.assertEqual(self.accepted(), retained) - - def test_failed_authentication_never_reaches_durable_acceptance(self) -> None: - self.assertEqual(self.request(valid=False), 400) - self.assertEqual(self.accepted(), []) - - def test_storage_failure_is_retryable_and_never_acknowledged(self) -> None: - connection = sqlite3.connect(self.root / "webhook-inbox.sqlite3") - try: - connection.execute( - "CREATE TRIGGER refuse_acceptance BEFORE INSERT ON accepted_events " - "BEGIN SELECT RAISE(ABORT, 'synthetic storage refusal'); END" - ) - connection.commit() - self.assertEqual(self.request(), 503) - self.assertEqual(self.accepted(), []) - connection.execute("DROP TRIGGER refuse_acceptance") - connection.commit() - finally: - connection.close() - self.assertEqual(self.request(attempt=2), 204) - self.assertEqual(len(self.accepted()), 1) - + DEMO.prepare_dev(root, REPO / "products/breg/acceptance/business-establishments", "business-establishments") + + def test_launcher_refuses_existing_state_without_touching_it(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + state = Path(temporary.name) / "existing" + state.mkdir() + sentinel = state / "sentinel" + sentinel.write_text("keep") + result = subprocess.run( + [str(REPO / "products/breg/demo/run.sh"), "--state-dir", str(state), "--smoke"], + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 2) + self.assertEqual(sentinel.read_text(), "keep") + self.assertIn("state path already exists", result.stderr) if __name__ == "__main__": unittest.main() From 2a1d774cd8e78d63fccdfae4b340bafde0ab6148 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:03:14 +0700 Subject: [PATCH 067/120] fix(fixtures): remove obsolete Evidence grant claim fields Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/tests/doctor.rs | 2 -- crates/registry-evidencectl/tests/production_build.rs | 2 -- .../farmer-landholding-evidence/evidence/provider/evidence.yaml | 2 -- 3 files changed, 6 deletions(-) diff --git a/crates/registry-evidencectl/tests/doctor.rs b/crates/registry-evidencectl/tests/doctor.rs index 02a16f8575..c7f685dc0a 100644 --- a/crates/registry-evidencectl/tests/doctor.rs +++ b/crates/registry-evidencectl/tests/doctor.rs @@ -574,8 +574,6 @@ fn provision(project: &Path) { principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority signing: secret:file/signing-p256-private-jwk audit: secret:file/audit-hmac-key subjectBinding: secret:file/subject-binding-hmac-key diff --git a/crates/registry-evidencectl/tests/production_build.rs b/crates/registry-evidencectl/tests/production_build.rs index a554bc0514..4f3639c07f 100644 --- a/crates/registry-evidencectl/tests/production_build.rs +++ b/crates/registry-evidencectl/tests/production_build.rs @@ -1264,8 +1264,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: {format: keyed-jsonl, hashSecretRef: 'secret:file/audit-hmac-key', hashKeyVersion: 1, failClosed: true} diff --git a/products/breg/acceptance/farmer-landholding-evidence/evidence/provider/evidence.yaml b/products/breg/acceptance/farmer-landholding-evidence/evidence/provider/evidence.yaml index 2d436b8f54..c8dbbae67e 100644 --- a/products/breg/acceptance/farmer-landholding-evidence/evidence/provider/evidence.yaml +++ b/products/breg/acceptance/farmer-landholding-evidence/evidence/provider/evidence.yaml @@ -19,8 +19,6 @@ authentication: principalClaim: sub requesterTagsClaim: evidence_tags evidenceAudienceClaim: evidence_audience - grantIdClaim: evidence_grant_id - grantAuthorityClaim: evidence_authority maximumTokenLifetimeSeconds: 300 revokedKeyIds: [] audit: From 86928ae530d6f79396023e0dcc484503f55dfdac Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:03:18 +0700 Subject: [PATCH 068/120] fix(evidence): keep source credential retries private Signed-off-by: Jeremi Joslin --- crates/registry-evidencectl/src/source_add.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/registry-evidencectl/src/source_add.rs b/crates/registry-evidencectl/src/source_add.rs index 389b071e47..63586a9ba1 100644 --- a/crates/registry-evidencectl/src/source_add.rs +++ b/crates/registry-evidencectl/src/source_add.rs @@ -6,7 +6,7 @@ use std::{ ffi::OsString, fs, io::{IsTerminal as _, Read as _, Write as _}, - os::unix::fs::MetadataExt as _, + os::unix::fs::{MetadataExt as _, PermissionsExt as _}, path::{Path, PathBuf}, process::{Command, ExitCode, Stdio}, time::Duration, @@ -907,7 +907,10 @@ fn check_credential_outputs( // The public provider export owns credential identity and validity. Stage // its retained pair privately so preflight never fills a missing output, // including while a review reports the choices it would apply. - let temporary = tempfile::tempdir().context("staging the retained credential comparison")?; + let temporary = tempfile::Builder::new() + .permissions(fs::Permissions::from_mode(0o700)) + .tempdir() + .context("staging the retained credential comparison")?; let temporary_path = fs::canonicalize(temporary.path()) .context("resolving the private credential staging directory")?; invoke(&export_client_args( @@ -1028,7 +1031,6 @@ fn provider_refusal(arguments: &[OsString], bytes: &[u8]) -> anyhow::Error { mod tests { use super::*; use clap::Parser as _; - use std::os::unix::fs::PermissionsExt as _; fn args(registry: &Path, project: &Path) -> SourceAddArgs { let cli = crate::Cli::try_parse_from([ @@ -1153,6 +1155,11 @@ mod tests { fs::canonicalize(path.parent().unwrap())?, path.parent().unwrap() ); + assert_eq!( + fs::metadata(path.parent().unwrap())?.permissions().mode() & 0o077, + 0, + "a provider credential export must use an owner-only directory" + ); if path.exists() { if fs::read(&path)? != bytes { bail!("credential output conflicts with the retained pair"); From 4337972a289631a8015f3a435b001b29b117bd50 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:03:22 +0700 Subject: [PATCH 069/120] test(breg): bind Evidence source OAuth context Signed-off-by: Jeremi Joslin --- .../breg/evidence/tests/verify-composition.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/products/breg/evidence/tests/verify-composition.py b/products/breg/evidence/tests/verify-composition.py index f61106b03c..c8e885e099 100644 --- a/products/breg/evidence/tests/verify-composition.py +++ b/products/breg/evidence/tests/verify-composition.py @@ -529,7 +529,14 @@ def history() -> bytes: assert registrations == (state_root / "clients.json").read_bytes() settings = yaml.safe_load((project / "targets/local/settings.yaml").read_text()) settings["governance"]["sourceConnections"]["registry"]["baseUrl"] = session["bregUrl"] - settings["governance"]["sourceConnections"]["registry"]["authentication"]["tokenEndpoint"] = session["tokenEndpoint"] + source_authentication = settings["governance"]["sourceConnections"]["registry"][ + "authentication" + ] + source_authentication["tokenEndpoint"] = session["tokenEndpoint"] + source_authentication["clientAssertionAudience"] = session["clientAssertionAudience"] + source_authentication["audience"] = session["audience"] + source_authentication["resource"] = session["resource"] + source_authentication["scope"] = " ".join(source["scopes"]) settings["runtime"]["bundleDirectory"] = str(candidate / "bundle") settings["runtime"]["secretProviders"]["file"]["root"] = str(project / "secrets") settings["runtime"]["auditStorage"]["path"] = str(project / "audit/evidence.jsonl") @@ -540,6 +547,16 @@ def history() -> bytes: command("evidencectl", "source", "import", exported, "--project", project, "--target", target) command("evidencectl", "fixtures", "run", "--project", project, "--target", target) command("evidencectl", "build", "--project", project, "--target", target, "--output", candidate) + compiled_authentication = yaml.safe_load( + (candidate / "bundle/evidence.yaml").read_text() + )["sourceConnections"]["registry"]["authentication"] + assert ( + compiled_authentication["clientAssertionAudience"] + == session["clientAssertionAudience"] + ) + assert compiled_authentication["audience"] == session["audience"] + assert compiled_authentication["resource"] == session["resource"] + assert compiled_authentication["scope"] == " ".join(source["scopes"]) restarted = json.loads(command("bregctl", "dev", "start", registry, "--format", "json")) if late: assert restarted["packageRevision"] != session["packageRevision"], "source needs a successor" From 6cdfe55c9a1104ceff023ee8a7b2b718052e3bb1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:06:31 +0700 Subject: [PATCH 070/120] fix(fixtures): identify farmer Evidence service actor Signed-off-by: Jeremi Joslin --- .../farmer-landholding-evidence/evidence/run-provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/products/breg/acceptance/farmer-landholding-evidence/evidence/run-provider.py b/products/breg/acceptance/farmer-landholding-evidence/evidence/run-provider.py index 4adb79aa5d..82a324baf9 100644 --- a/products/breg/acceptance/farmer-landholding-evidence/evidence/run-provider.py +++ b/products/breg/acceptance/farmer-landholding-evidence/evidence/run-provider.py @@ -182,7 +182,8 @@ def do_POST(self): bundle.chmod(0o555) now = int(time.time()) claims = {"iss": issuer, "aud": "farmer-evidence-trial", "sub": "registered-breg-procedure", "iat": now - 1, "exp": now + 299, - "evidence_tags": ["breg-farmer-procedure"], "evidence_audience": "urn:example:landholding"} + "registry_actor_kind": "service", "evidence_tags": ["breg-farmer-procedure"], + "evidence_audience": "urn:example:landholding"} token = sign_token(issuer_pem, issuer_public, claims) token_path = output / "token" token_path.write_text(token) From 96bb10214013ca5a8453ec04cdacf7febb6e105d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:17:48 +0700 Subject: [PATCH 071/120] refactor(breg): move load tests to dev lifecycle Signed-off-by: Jeremi Joslin --- products/breg/loadtest/README.md | 111 ++-- products/breg/loadtest/dbstats.sh | 39 +- products/breg/loadtest/down.sh | 65 +- products/breg/loadtest/lib/token.js | 43 -- products/breg/loadtest/lib/workload.js | 16 +- products/breg/loadtest/profiles/burst.js | 11 +- .../breg/loadtest/profiles/cursor-smoke.js | 2 +- products/breg/loadtest/profiles/herd.js | 41 -- products/breg/loadtest/profiles/steady.js | 4 +- products/breg/loadtest/profiles/sweep.js | 2 +- products/breg/loadtest/profiles/token-soak.js | 42 -- products/breg/loadtest/run.sh | 95 +-- products/breg/loadtest/seed.py | 93 +-- products/breg/loadtest/support/evidence.py | 115 ---- products/breg/loadtest/support/loadenv.py | 585 ++++-------------- .../breg/loadtest/support/test_evidence.py | 86 ++- products/breg/loadtest/up.sh | 299 ++------- 17 files changed, 457 insertions(+), 1192 deletions(-) delete mode 100644 products/breg/loadtest/lib/token.js delete mode 100644 products/breg/loadtest/profiles/herd.js delete mode 100644 products/breg/loadtest/profiles/token-soak.js diff --git a/products/breg/loadtest/README.md b/products/breg/loadtest/README.md index d1016053b5..b9ebb8f061 100644 --- a/products/breg/loadtest/README.md +++ b/products/breg/loadtest/README.md @@ -1,16 +1,21 @@ # Base Registry Engine load-test environment -A self-contained local stack for measuring Base Registry Engine with pinned -PostgreSQL 17, Registry Mint, the `business-establishments` acceptance fixture, -and the opt-in private metrics listener. Everything disposable lives under -`loadtest/.run` and is never committed. - -All seeded data is synthetic and deterministically generated from a fixed -seed. No real business, person, or identifier is involved. +A local environment for measuring Base Registry Engine with the +`business-establishments` acceptance fixture. The launcher delegates the whole +service lifecycle to `bregctl dev`: its pinned PostgreSQL 17 container, stock +ThunderID 1.0.1 issuer, package rehearsal and activation, credentials, retained +state, and supervised BReg process. Load evidence and deterministic synthetic +seed data live under `loadtest/.run` and are never committed. + +The acceptance fixture's alternate no-purpose schema-test step is omitted from +the copied load project because a native development project binds exactly one +client to each access profile. The fixture continues to own that authorization +proof. The load client carries the ordinary `business-operator` claims used by +the remaining journey and workload. ## Prerequisites -- `cargo`, `docker`, `openssl`, `python3`, `uv` +- `cargo`, `docker`, `python3` - `k6` (`brew install k6`) ## Quick start @@ -24,10 +29,17 @@ products/breg/loadtest/run.sh --profile sweep products/breg/loadtest/down.sh ``` -`up.sh --pool-max N` sizes the PostgreSQL pool (default 32, bound 1..128). `seed.py --workers N` bounds concurrent batch requests (default 4). Seeds of 100,000 records or more run PostgreSQL `ANALYZE` after import. The documented -full-scale seed is 500,000 records. +full-scale seed is 500,000 records. The stock development runtime fixes its +database pool at 4 connections; this harness does not patch private runtime +configuration behind the lifecycle's ownership boundary. + +`up.sh` refuses any existing `.run` path and does not clear it. `down.sh` first +validates the launcher's ownership marker, then calls `bregctl dev stop +--remove` for that exact project. It retains the load evidence and synthetic +seed files. Move the stopped `.run` directory aside before starting another +environment. ## Operations are not HTTP requests @@ -35,8 +47,7 @@ Rate settings are operations per second (`OPS`), not HTTP TPS. A workload operation can issue more than one request: - a paginated list follows page two when a cursor is present; -- a patch first fetches the current ETag; -- a virtual user may refresh its cached access token. +- a patch first fetches the current ETag. Every result therefore reports offered operations/s, achieved operations/s, and actual HTTP requests/s separately. @@ -45,17 +56,23 @@ and actual HTTP requests/s separately. | Profile | Default shape | Question it answers | |---|---|---| -| `cursor-smoke` | one filtered page plus its continuation | Did the harness really execute page two with the Base Registry Engine cursor contract? | -| `steady` | 50 operations/s, mixed workload, 10 min | Does the target rate hold without drops or failures and with p99 below 250 ms? | +| `cursor-smoke` | one filtered page plus its continuation | Did the harness really execute page two with the BReg cursor contract? | +| `steady` | 50 operations/s, mixed workload, 3 min | Does the target rate hold without drops or failures and with p99 below 250 ms? | | `sweep` | excluded warmup at 50 operations/s, then independent 2 min holds at 50, 75, 100, 125, and 150 | At which held rate do drops, errors, or tail-latency failure begin? | -| `burst` | 50 operations/s baseline, 30 s ramp to 250, 30 s hold, 30 s ramp down, 3 min recovery | Is a 5x campaign burst graceful, and does the service return to its baseline SLO? | -| `herd` | 200 VUs, one token and one protected read each | Does a coordinated client restart overload Mint or Base Registry Engine? | -| `token-soak` | ramp to 200 VUs for 1 min | How does sustained token minting behave? This is intentionally separate from the one-shot herd. | +| `burst` | 50 operations/s for 30 s, 15 s ramp to 250, 30 s hold, 15 s ramp down, 90 s recovery | Is a 5x campaign burst graceful, and does the service return to its baseline SLO? | The steady mix is 40% code lookup, 30% point get, 20% filtered list, 7% create, and 3% preconditioned patch. Sweep and burst are read-only. Workload selection uses a deterministic per-VU PRNG (`RANDOM_SEED=20260902` by default). +The harness deliberately does not benchmark ThunderID. Token-soak and +coordinated issuer-herd profiles measure the external issuer rather than BReg +capacity, so they are outside this harness. Before every k6 process, `run.sh` calls `bregctl dev token +loadtest-driver PROJECT`, validates its reported owner-only header file, and +gives k6 only that path. Each k6 invocation must fit within four minutes so the +single short-lived development token remains usable. Sweep obtains a fresh +token for its warmup and for every independent hold. + ## Tuning `run.sh` accepts `--profile`, `--ops`, and `--duration`; other non-sensitive @@ -63,17 +80,15 @@ arguments pass through to k6. HTTP debug and system-tag overrides are refused because they can expose credentials, cursors, URLs, or record identifiers. Profile-specific environment variables are: -- `steady`: `OPS=50`, `DURATION=10m`, `FOLLOW_CURSOR=1` +- `steady`: `OPS=50`, `DURATION=3m`, `FOLLOW_CURSOR=1` - `sweep`: `RATES=50,75,100,125,150`, `HOLD=2m`, `WARMUP_OPS=50`, `WARMUP_DURATION=2m` -- `burst`: `OPS=50`, `PEAK_OPS=250`, `BASELINE_DURATION=2m`, `RAMP_DURATION=30s`, `PEAK_DURATION=30s`, `RECOVERY_DURATION=3m` -- `herd`: `VUS=200`, `DURATION=30s` (maximum completion time) -- `token-soak`: `VUS=200`, `DURATION=1m` +- `burst`: `OPS=50`, `PEAK_OPS=250`, `BASELINE_DURATION=30s`, `RAMP_DURATION=15s`, `PEAK_DURATION=30s`, `RECOVERY_DURATION=90s` - all workload profiles: `RANDOM_SEED`, `FOLLOW_CURSOR` Examples: ```bash -products/breg/loadtest/run.sh --profile steady --ops 75 --duration 15m +products/breg/loadtest/run.sh --profile steady --ops 75 --duration 4m RATES=50,60,70,80,90 HOLD=3m products/breg/loadtest/run.sh --profile sweep PEAK_OPS=300 products/breg/loadtest/run.sh --profile burst ``` @@ -83,54 +98,45 @@ PEAK_OPS=300 products/breg/loadtest/run.sh --profile burst Each measured run gets an owner-only directory under `.run/results/` with: - `manifest.json`: Git revision and dirty state, non-secret host/tool versions, - pool size, seed counts, exact profile parameters, and timestamps; + fixed development pool size, seed counts, exact profile parameters, and + timestamps; - `k6-summary.json` and `k6-samples.json`: threshold data and raw metric samples with the system tag set restricted to status, method, operation name, scenario, and expected-response status; -- `telemetry.jsonl`: one-second Base Registry Engine metrics plus local server and - Mint CPU/RSS samples; -- `db-before.json`, `db-waits.jsonl`, and `db-after.json`: per-run-reset - statement timing by safe category/query id, continuous wait counts, table - sizes, and audit-chain length; -- `result.json`: the mechanically generated throughput, errors, drops, 504s, - p50/p95/p99 by operation and phase, telemetry peaks, DB wait peaks, and SLO - verdict; -- `safety.json`: evidence scan proving configured secrets, seeded record-id - canaries, compact JWTs, unsafe k6 tags, SQL text, and response bodies were not - persisted. +- `db-before.json`, `db-waits.jsonl`, and `db-after.json`: continuous wait + counts, table sizes, and audit-chain length from the development database; +- `result.json`: throughput, errors, drops, 504s, p50/p95/p99 by operation and + phase, DB wait peaks, and the SLO verdict; +- `safety.json`: evidence scan proving the current authorization header, + seeded record-id canaries, compact JWTs, unsafe k6 tags, SQL text, and + response bodies were not persisted. The sweep also creates `sweep-result.json`, including the first held rate that -failed its thresholds. The warmup is deliberately excluded from measurement, -and PostgreSQL statement statistics are reset before every held rate. +failed its thresholds. The warmup is excluded from measurement. The stock dev +database does not enable `pg_stat_statements`, so this harness does not claim +per-statement timings. -The harness never saves bearer tokens, client secrets, cursors, source records, +The harness never saves bearer tokens, assertion keys, cursors, source records, raw principals, request/response bodies, audit payloads, SQL text, or bound SQL -values. It passes secrets to k6 through the process environment, not command -arguments. +values. The only authorization file is the owner-only header managed by +`bregctl dev` inside the project's ignored private state. ## Database diagnostics -The run wrapper captures DB diagnostics automatically. These commands are also +The run wrapper captures diagnostics automatically. These commands are also available for focused investigation: ```bash -products/breg/loadtest/dbstats.sh reset products/breg/loadtest/dbstats.sh snapshot products/breg/loadtest/dbstats.sh sample 1 products/breg/loadtest/dbstats.sh analyze ``` -`pg_stat_statements` is cumulative until reset. A point-in-time snapshot alone -cannot attribute time to a profile or establish peak lock/pool pressure, which -is why measured runs reset it and sample waits continuously. - ## Interpreting results - The audit chain is a strong bottleneck hypothesis because audited requests - serialize updates to a singleton chain head, but the harness should prove - it per run using audit wait peaks and post-reset statement timing. -- Rising latency with low pool waiters points toward transaction/query work. - Rising pool waiters indicates pool pressure. + serialize updates to a singleton chain head, but the harness should prove it + per run using audit lock waits. - 504 `request.timeout` responses are saturation, not successful throughput. - Capacity is the highest held rate that meets its full thresholds, not a rate merely touched during a ramp. @@ -147,10 +153,11 @@ bash -n products/breg/loadtest/{up,down,run,dbstats}.sh for profile in products/breg/loadtest/profiles/*.js; do k6 inspect -e \ ESTABLISHMENT_IDS_FILE=products/breg/loadtest/.run/seed/establishment-ids.txt \ + -e AUTHORIZATION_HEADER_FILE=products/breg/loadtest/.run/project/.breg/dev/secrets/loadtest-driver.header \ "$profile" >/dev/null done ``` -The inspect loop requires the synthetic seed pool because workload profiles -load it during k6 initialization. The live `cursor-smoke` is the end-to-end -proof that continuation actually occurs. +The inspect loop uses the synthetic seed pool and fresh header from a running +environment because workload profiles load both during k6 initialization. The +live `cursor-smoke` is the end-to-end proof that continuation actually occurs. diff --git a/products/breg/loadtest/dbstats.sh b/products/breg/loadtest/dbstats.sh index d87a149ec1..931e80e154 100755 --- a/products/breg/loadtest/dbstats.sh +++ b/products/breg/loadtest/dbstats.sh @@ -7,9 +7,9 @@ action="${1:-snapshot}" interval="${2:-1}" case "$action" in - reset|snapshot|sample|analyze) ;; + snapshot|sample|analyze) ;; *) - printf '%s\n' 'usage: products/breg/loadtest/dbstats.sh reset|snapshot|sample [interval-seconds]|analyze' >&2 + printf '%s\n' 'usage: products/breg/loadtest/dbstats.sh snapshot|sample [interval-seconds]|analyze' >&2 exit 2 ;; esac @@ -26,9 +26,18 @@ fi read -r container database < <(python3 - "$run_dir/env.json" <<'PY' import json import sys +from pathlib import Path environment = json.load(open(sys.argv[1], encoding="utf-8")) -print(environment["database"]["container"], environment["database"]["database"]) +root=Path(sys.argv[1]).resolve().parent +if (root/'.launcher-owned').read_text().strip()!='registry-stack-breg-loadtest-v2': raise SystemExit(2) +project=(root/'project').resolve() +if Path(environment['project']).resolve()!=project: raise SystemExit(2) +state=json.load(open(project/'.breg/dev/state.json', encoding='utf-8')) +container=environment["database"]["container"] +if container!=state.get('containerId') or len(container)!=64 or any(c not in '0123456789abcdefABCDEF' for c in container): raise SystemExit(2) +if environment["database"]["database"]!='breg_dev': raise SystemExit(2) +print(container, 'breg_dev') PY ) @@ -36,11 +45,6 @@ psql_exec() { docker exec -i "$container" psql -v ON_ERROR_STOP=1 -q -U postgres -d "$database" "$@" } -if [[ "$action" == reset ]]; then - psql_exec -Atc 'SELECT pg_stat_statements_reset() IS NOT NULL;' >/dev/null - exit 0 -fi - if [[ "$action" == analyze ]]; then psql_exec -c 'ANALYZE;' >/dev/null exit 0 @@ -48,23 +52,7 @@ fi if [[ "$action" == snapshot ]]; then psql_exec -At <<'SQL' -WITH top_statements AS ( - SELECT queryid::text AS "queryId", - CASE - WHEN strpos(lower(query), 'registry_audit_head') > 0 THEN 'audit-head' - WHEN strpos(lower(query), 'registry_audit') > 0 THEN 'audit' - WHEN strpos(lower(query), 'breg_e_') > 0 THEN 'record' - ELSE 'other' - END AS category, - calls, - round(total_exec_time::numeric, 3) AS "totalMs", - round(mean_exec_time::numeric, 3) AS "meanMs", - rows - FROM pg_stat_statements - WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) - ORDER BY total_exec_time DESC - LIMIT 20 -), table_sizes AS ( +WITH table_sizes AS ( SELECT relname AS name, pg_total_relation_size(relid) AS "totalBytes", n_live_tup AS "liveRows", @@ -82,7 +70,6 @@ WITH top_statements AS ( ) SELECT json_build_object( 'timestamp', clock_timestamp(), - 'topStatements', COALESCE((SELECT json_agg(top_statements) FROM top_statements), '[]'::json), 'currentWaits', COALESCE((SELECT json_agg(current_waits) FROM current_waits), '[]'::json), 'tableSizes', COALESCE((SELECT json_agg(table_sizes) FROM table_sizes), '[]'::json), 'auditRows', (SELECT count(*) FROM registry_internal.registry_audit) diff --git a/products/breg/loadtest/down.sh b/products/breg/loadtest/down.sh index 79e3aba500..79e0c04f5a 100755 --- a/products/breg/loadtest/down.sh +++ b/products/breg/loadtest/down.sh @@ -2,53 +2,34 @@ set -euo pipefail loadtest_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repository_root=$(cd -- "$loadtest_dir/../../.." && pwd) run_dir="$loadtest_dir/.run" +marker="$run_dir/.launcher-owned" -if [[ ! -f "$run_dir/env.json" ]]; then - printf '%s\n' "No recorded load-test environment at $run_dir/env.json." >&2 +if [[ $# -ne 0 ]]; then + printf '%s\n' 'usage: products/breg/loadtest/down.sh' >&2 exit 2 fi - -container=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["database"]["container"])' "$run_dir/env.json") -if [[ ! "$container" =~ ^breg-loadtest-[0-9]+-[0-9]+$ ]]; then - printf '%s\n' 'Recorded container name is not a Base Registry Engine load-test target; refusing teardown.' >&2 +if ! command -v docker >/dev/null 2>&1; then + printf '%s\n' 'docker is required to stop the owned bregctl development session.' >&2 exit 2 fi - -validated_pid() { - local pid_file="$1" - local expected_config="$2" - local label="$3" - if [[ ! -f "$pid_file" ]]; then - return 0 - fi - local pid - pid=$(<"$pid_file") - if [[ ! "$pid" =~ ^[0-9]+$ ]]; then - printf '%s\n' "Recorded $label PID is invalid; refusing teardown." >&2 - return 2 - fi - if ! kill -0 "$pid" 2>/dev/null; then - return 0 - fi - local command - command=$(ps -ww -p "$pid" -o command= 2>/dev/null || true) - if [[ "$command" != *"$expected_config"* ]]; then - printf '%s\n' "Recorded $label PID now belongs to another process; refusing teardown." >&2 - return 2 - fi - printf '%s\n' "$pid" -} - -breg_pid=$(validated_pid "$run_dir/breg.pid" "$run_dir/runtime.yaml" 'Base Registry Engine') || exit $? -mint_pid=$(validated_pid "$run_dir/mint.pid" "$run_dir/mint/mint.yaml" 'Registry Mint') || exit $? - -if [[ -n "$breg_pid" ]]; then - kill "$breg_pid" >/dev/null 2>&1 || true +if [[ -L "$run_dir" || ! -d "$run_dir" || ! -f "$marker" ]] || + [[ "$(<"$marker")" != registry-stack-breg-loadtest-v2 ]]; then + printf '%s\n' 'No owned Base Registry Engine load-test state was found; refusing teardown.' >&2 + exit 2 fi -if [[ -n "$mint_pid" ]]; then - kill "$mint_pid" >/dev/null 2>&1 || true +if [[ -L "$run_dir/project" || ! -d "$run_dir/project" || ! -f "$run_dir/env.json" ]]; then + printf '%s\n' 'Owned load-test state is incomplete; refusing teardown.' >&2 + exit 2 fi -docker rm -f "$container" >/dev/null 2>&1 || true -rm -f "$run_dir/breg.pid" "$run_dir/mint.pid" "$run_dir/env.json" -printf '%s\n' 'Load-test environment stopped. The .run directory was kept for logs and seeds; up.sh clears it on the next start.' + +bregctl=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["bregctl"])' "$run_dir/env.json") +if [[ "$bregctl" != "$repository_root/target/debug/bregctl" || ! -x "$bregctl" ]]; then + printf '%s\n' 'The bregctl recorded by this environment is unavailable; preserve state and restore the matching build.' >&2 + exit 2 +fi + +"$bregctl" --format json dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >"$run_dir/stop-report.json" +printf '%s\n' 'Load-test services and their owned database were removed through bregctl dev.' +printf '%s\n' "Evidence and synthetic seeds remain at $run_dir; move that directory aside before another start." diff --git a/products/breg/loadtest/lib/token.js b/products/breg/loadtest/lib/token.js deleted file mode 100644 index 570c400adf..0000000000 --- a/products/breg/loadtest/lib/token.js +++ /dev/null @@ -1,43 +0,0 @@ -// Token acquisition for the load-test harness. -// -// k6 acquires access tokens from Registry Mint with plain client_secret_post -// requests (the same OAuth client-credential grant the real driver client -// uses), caching each token until shortly before its expiry. The HERD mode -// bypasses the cache so every iteration exercises the token endpoint itself. - -import http from 'k6/http'; - -const REFRESH_MARGIN_SECONDS = 60; - -const state = { - token: '', - expiresAt: 0, -}; - -export function driverToken(tokenUrl, clientId, clientSecret, { herd = false } = {}) { - if (!herd) { - const now = Date.now() / 1000; - if (state.token && now < state.expiresAt) { - return state.token; - } - } - const response = http.post( - tokenUrl, - { - grant_type: 'client_credentials', - client_id: clientId, - client_secret: clientSecret, - }, - { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, tags: { name: 'mint_token' } } - ); - if (response.status !== 200) { - throw new Error(`token endpoint returned ${response.status}`); - } - const body = response.json(); - if (!body.access_token) { - throw new Error('token endpoint returned no access_token'); - } - state.token = body.access_token; - state.expiresAt = Date.now() / 1000 + (body.expires_in || 300) - REFRESH_MARGIN_SECONDS; - return state.token; -} diff --git a/products/breg/loadtest/lib/workload.js b/products/breg/loadtest/lib/workload.js index 2ede3d2ba3..a84dd7b991 100644 --- a/products/breg/loadtest/lib/workload.js +++ b/products/breg/loadtest/lib/workload.js @@ -10,10 +10,15 @@ import http from 'k6/http'; import { check } from 'k6'; import { SharedArray } from 'k6/data'; import { Counter } from 'k6/metrics'; -import { driverToken } from './token.js'; export const cursorPagesFollowed = new Counter('cursor_pages_followed'); +const authorization = open(__ENV.AUTHORIZATION_HEADER_FILE, 'r').trim(); +if (!authorization.startsWith('Authorization: Bearer ') || authorization.split('.').length !== 3) { + throw new Error('AUTHORIZATION_HEADER_FILE must contain one bregctl dev Authorization header'); +} +const authorizationValue = authorization.slice('Authorization: '.length); + export const establishmentIds = new SharedArray('establishmentIds', function () { return sharedLines(__ENV.ESTABLISHMENT_IDS_FILE, 'id'); }); @@ -38,21 +43,18 @@ function sharedLines(path, column) { } function headers(token, extra = {}) { - return Object.assign({ Authorization: `Bearer ${token}` }, extra); + return Object.assign({ Authorization: token }, extra); } export class Workload { - constructor(baseUrl, tokenUrl, clientId, clientSecret) { + constructor(baseUrl) { this.baseUrl = baseUrl; - this.tokenUrl = tokenUrl; - this.clientId = clientId; - this.clientSecret = clientSecret; this.createCounter = 0; this.randomState = 0; } token() { - return driverToken(this.tokenUrl, this.clientId, this.clientSecret); + return authorizationValue; } random() { diff --git a/products/breg/loadtest/profiles/burst.js b/products/breg/loadtest/profiles/burst.js index 74fa237e12..0c30707e44 100644 --- a/products/breg/loadtest/profiles/burst.js +++ b/products/breg/loadtest/profiles/burst.js @@ -1,5 +1,6 @@ // Campaign burst with named phases so recovery is measured separately. -// Defaults: 50 ops/s baseline, ramp to 250, hold 30s, return to 50, recover 3m. +// Defaults fit one fresh development token: 30s baseline, 15s ramps, a 30s +// peak, and 90s recovery. import { SAFE_SYSTEM_TAGS, @@ -15,10 +16,10 @@ const baselineOps = positiveNumber('OPS', __ENV.OPS, 50); const peakOps = positiveNumber('PEAK_OPS', __ENV.PEAK_OPS, 250); if (peakOps <= baselineOps) throw new Error('PEAK_OPS must be greater than OPS'); -const baselineDuration = __ENV.BASELINE_DURATION || '2m'; -const rampDuration = __ENV.RAMP_DURATION || '30s'; +const baselineDuration = __ENV.BASELINE_DURATION || '30s'; +const rampDuration = __ENV.RAMP_DURATION || '15s'; const peakDuration = __ENV.PEAK_DURATION || '30s'; -const recoveryDuration = __ENV.RECOVERY_DURATION || '3m'; +const recoveryDuration = __ENV.RECOVERY_DURATION || '90s'; const baselineMs = durationMilliseconds('BASELINE_DURATION', baselineDuration); const rampMs = durationMilliseconds('RAMP_DURATION', rampDuration); const peakMs = durationMilliseconds('PEAK_DURATION', peakDuration); @@ -95,7 +96,7 @@ export const options = { noConnectionReuse: false, }; -const workload = new Workload(__ENV.BREG_URL, __ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET); +const workload = new Workload(__ENV.BREG_URL); export function campaignStep() { workload.step(workload.token(), READ_MIX); diff --git a/products/breg/loadtest/profiles/cursor-smoke.js b/products/breg/loadtest/profiles/cursor-smoke.js index 5b3df81f18..2660ae73d6 100644 --- a/products/breg/loadtest/profiles/cursor-smoke.js +++ b/products/breg/loadtest/profiles/cursor-smoke.js @@ -17,7 +17,7 @@ export const options = { summaryTrendStats: SUMMARY_TREND_STATS, }; -const workload = new Workload(__ENV.BREG_URL, __ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET); +const workload = new Workload(__ENV.BREG_URL); export default function () { workload.filteredList(workload.token()); diff --git a/products/breg/loadtest/profiles/herd.js b/products/breg/loadtest/profiles/herd.js deleted file mode 100644 index 5506217892..0000000000 --- a/products/breg/loadtest/profiles/herd.js +++ /dev/null @@ -1,41 +0,0 @@ -// A true coordinated restart: every virtual user authenticates once and then -// performs one protected read. per-vu-iterations starts the users together. - -import http from 'k6/http'; -import { check } from 'k6'; -import { SAFE_SYSTEM_TAGS, SUMMARY_TREND_STATS, positiveInteger } from '../lib/config.js'; -import { writeSummary } from '../lib/summary.js'; -import { driverToken } from '../lib/token.js'; - -const vus = positiveInteger('VUS', __ENV.VUS, 200); -const maxDuration = __ENV.DURATION || '30s'; - -export const options = { - scenarios: { - coordinated_restart: { - executor: 'per-vu-iterations', - vus, - iterations: 1, - maxDuration, - }, - }, - thresholds: { - dropped_iterations: ['count==0'], - http_req_failed: ['rate==0'], - 'http_req_duration{name:mint_token}': ['p(99)<500'], - }, - systemTags: SAFE_SYSTEM_TAGS, - summaryTrendStats: SUMMARY_TREND_STATS, - noConnectionReuse: false, -}; - -export default function () { - const token = driverToken(__ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET, { herd: true }); - const response = http.get( - `${__ENV.BREG_URL}/v1/records/establishments?accessProfile=business-operator&$top=1`, - { headers: { Authorization: `Bearer ${token}` }, tags: { name: 'herd_read' } } - ); - check(response, { 'herd read status 200': (r) => r.status === 200 }); -} - -export const handleSummary = writeSummary; diff --git a/products/breg/loadtest/profiles/steady.js b/products/breg/loadtest/profiles/steady.js index 51eb249d58..3064e54b72 100644 --- a/products/breg/loadtest/profiles/steady.js +++ b/products/breg/loadtest/profiles/steady.js @@ -5,7 +5,7 @@ import { writeSummary } from '../lib/summary.js'; import { Workload, STEADY_MIX } from '../lib/workload.js'; const ops = positiveNumber('OPS', __ENV.OPS, 50); -const duration = __ENV.DURATION || '10m'; +const duration = __ENV.DURATION || '3m'; export const options = { scenarios: { @@ -30,7 +30,7 @@ export const options = { noConnectionReuse: false, }; -const workload = new Workload(__ENV.BREG_URL, __ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET); +const workload = new Workload(__ENV.BREG_URL); export default function () { workload.step(workload.token(), STEADY_MIX); diff --git a/products/breg/loadtest/profiles/sweep.js b/products/breg/loadtest/profiles/sweep.js index bc5b078cee..2c3bbd03ea 100644 --- a/products/breg/loadtest/profiles/sweep.js +++ b/products/breg/loadtest/profiles/sweep.js @@ -29,7 +29,7 @@ export const options = { noConnectionReuse: false, }; -const workload = new Workload(__ENV.BREG_URL, __ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET); +const workload = new Workload(__ENV.BREG_URL); export default function () { workload.step(workload.token(), READ_MIX); diff --git a/products/breg/loadtest/profiles/token-soak.js b/products/breg/loadtest/profiles/token-soak.js deleted file mode 100644 index 19d6c2b88a..0000000000 --- a/products/breg/loadtest/profiles/token-soak.js +++ /dev/null @@ -1,42 +0,0 @@ -// Sustained token-endpoint exercise, kept separate from the one-shot herd. - -import http from 'k6/http'; -import { check } from 'k6'; -import { SAFE_SYSTEM_TAGS, SUMMARY_TREND_STATS, positiveInteger } from '../lib/config.js'; -import { writeSummary } from '../lib/summary.js'; -import { driverToken } from '../lib/token.js'; - -const vus = positiveInteger('VUS', __ENV.VUS, 200); -const duration = __ENV.DURATION || '1m'; - -export const options = { - scenarios: { - token_soak: { - executor: 'ramping-vus', - startVUs: 0, - stages: [ - { duration: '5s', target: vus }, - { duration, target: vus }, - { duration: '5s', target: 0 }, - ], - }, - }, - thresholds: { - http_req_failed: ['rate==0'], - 'http_req_duration{name:mint_token}': ['p(99)<500'], - }, - systemTags: SAFE_SYSTEM_TAGS, - summaryTrendStats: SUMMARY_TREND_STATS, - noConnectionReuse: false, -}; - -export default function () { - const token = driverToken(__ENV.TOKEN_URL, __ENV.CLIENT_ID, __ENV.CLIENT_SECRET, { herd: true }); - const response = http.get( - `${__ENV.BREG_URL}/v1/records/establishments?accessProfile=business-operator&$top=1`, - { headers: { Authorization: `Bearer ${token}` }, tags: { name: 'token_soak_read' } } - ); - check(response, { 'token soak read status 200': (r) => r.status === 200 }); -} - -export const handleSummary = writeSummary; diff --git a/products/breg/loadtest/run.sh b/products/breg/loadtest/run.sh index b50901f898..19520c2130 100755 --- a/products/breg/loadtest/run.sh +++ b/products/breg/loadtest/run.sh @@ -8,7 +8,7 @@ evidence="$loadtest_dir/support/evidence.py" dbstats="$loadtest_dir/dbstats.sh" usage() { - printf '%s\n' "usage: products/breg/loadtest/run.sh --profile steady|sweep|burst|herd|token-soak|cursor-smoke [--ops N] [--duration 10m] [extra k6 args]" >&2 + printf '%s\n' "usage: products/breg/loadtest/run.sh --profile steady|sweep|burst|cursor-smoke [--ops N] [--duration 3m] [extra k6 args]" >&2 exit 2 } @@ -69,21 +69,49 @@ if [[ ! -f "$run_dir/seed/establishment-ids.txt" || ! -f "$run_dir/seed/seed-sum exit 2 fi -read -r breg_url metrics_url token_url driver_client_id < <(python3 - "$run_dir/env.json" <<'PY' +read -r breg_url bregctl project < <(python3 - "$run_dir/env.json" <<'PY' import json import sys environment = json.load(open(sys.argv[1], encoding="utf-8")) -print(environment["breg_url"], environment["metrics_url"], environment["token_url"], environment["driver_client_id"]) +print(environment["breg_url"], environment["bregctl"], environment["project"]) PY ) +if [[ "$bregctl" != "$repository_root/target/debug/bregctl" || "$project" != "$run_dir/project" ]]; then + printf '%s\n' 'load-test environment references paths outside its owned checkout state' >&2 + exit 2 +fi + +duration_seconds() { + python3 - "$1" <<'PY' +import re,sys +value=sys.argv[1] +parts=list(re.finditer(r'([0-9]+(?:[.][0-9]+)?)(ms|s|m|h)',value)) +if not parts or ''.join(part.group(0) for part in parts)!=value: + raise SystemExit(2) +scale={'ms':0.001,'s':1,'m':60,'h':3600} +print(sum(float(part.group(1))*scale[part.group(2)] for part in parts)) +PY +} + +require_token_window() { + local label="$1" + shift + local total=0 seconds value + for value in "$@"; do + seconds=$(duration_seconds "$value") || { + printf '%s\n' "$label contains an invalid k6 duration: $value" >&2 + exit 2 + } + total=$(python3 -c 'import sys; print(float(sys.argv[1])+float(sys.argv[2]))' "$total" "$seconds") + done + python3 -c 'import sys; raise SystemExit(0 if float(sys.argv[1]) <= 240 else 1)' "$total" || { + printf '%s\n' "$label must finish within 4 minutes so one fresh bregctl dev token remains valid." >&2 + exit 2 + } +} export BREG_URL="$breg_url" -export METRICS_URL="$metrics_url" -export TOKEN_URL="$token_url" -export CLIENT_ID="$driver_client_id" -CLIENT_SECRET="$(<"$run_dir/secrets/driver-client-secret")" -export CLIENT_SECRET export ESTABLISHMENT_IDS_FILE="$run_dir/seed/establishment-ids.txt" export FOLLOW_CURSOR="${FOLLOW_CURSOR:-1}" export RANDOM_SEED="${RANDOM_SEED:-20260902}" @@ -91,16 +119,10 @@ export RANDOM_SEED="${RANDOM_SEED:-20260902}" umask 077 mkdir -p "$run_dir/results" stamp="$(date -u +%Y%m%dT%H%M%SZ)-$$" -metrics_pid="" db_pid="" last_run_status=0 stop_samplers() { - if [[ -n "$metrics_pid" ]]; then - kill "$metrics_pid" 2>/dev/null || true - wait "$metrics_pid" 2>/dev/null || true - metrics_pid="" - fi if [[ -n "$db_pid" ]]; then kill "$db_pid" 2>/dev/null || true wait "$db_pid" 2>/dev/null || true @@ -129,16 +151,13 @@ run_one() { --profile "$manifest_profile" \ "${manifest_parameters[@]}" - "$dbstats" reset "$dbstats" snapshot >"$result_dir/db-before.json" export K6_SUMMARY_PATH="$result_dir/k6-summary.json" - python3 "$evidence" sample-metrics \ - --url "$metrics_url" \ - --out "$result_dir/telemetry.jsonl" \ - --server-pid "$run_dir/breg.pid" \ - --mint-pid "$run_dir/mint.pid" & - metrics_pid=$! + AUTHORIZATION_HEADER_FILE=$(python3 "$loadtest_dir/support/loadenv.py" header \ + --bregctl "$bregctl" --project "$project" --client loadtest-driver) + export AUTHORIZATION_HEADER_FILE + "$dbstats" sample 1 >"$result_dir/db-waits.jsonl" & db_pid=$! @@ -149,17 +168,7 @@ run_one() { stop_samplers "$dbstats" snapshot >"$result_dir/db-after.json" - local secret_arguments=() - local secret_path - for secret_path in \ - "$run_dir/secrets/"* \ - "$run_dir/keys/mint/"* \ - "$run_dir/keys/operator/"* \ - "$run_dir/tls/"*.key; do - if [[ -f "$secret_path" ]]; then - secret_arguments+=(--secret-file "$secret_path") - fi - done + local secret_arguments=(--secret-file "$AUTHORIZATION_HEADER_FILE") python3 "$evidence" assert-safe \ --artifact-dir "$result_dir" \ --samples "$result_dir/k6-samples.json" \ @@ -173,7 +182,6 @@ run_one() { --manifest "$result_dir/manifest.json" \ --k6-summary "$result_dir/k6-summary.json" \ --samples "$result_dir/k6-samples.json" \ - --telemetry "$result_dir/telemetry.jsonl" \ --db-after "$result_dir/db-after.json" \ --db-waits "$result_dir/db-waits.jsonl" \ --safety "$result_dir/safety.json" \ @@ -202,6 +210,8 @@ if [[ "$profile" == sweep ]]; then hold="${HOLD:-2m}" warmup_ops="${WARMUP_OPS:-50}" warmup_duration="${WARMUP_DURATION:-2m}" + require_token_window WARMUP_DURATION "$warmup_duration" + require_token_window HOLD "$hold" if [[ ! "$warmup_ops" =~ ^[0-9]+([.][0-9]+)?$ ]]; then printf '%s\n' 'WARMUP_OPS must be a positive number' >&2 exit 2 @@ -218,6 +228,9 @@ if [[ "$profile" == sweep ]]; then mkdir -m 700 "$sweep_root" printf '%s\n' "== read-only warmup: $warmup_ops ops/s for $warmup_duration (excluded from evidence)" export OPS="$warmup_ops" DURATION="$warmup_duration" K6_SUMMARY_PATH="" + AUTHORIZATION_HEADER_FILE=$(python3 "$loadtest_dir/support/loadenv.py" header \ + --bregctl "$bregctl" --project "$project" --client loadtest-driver) + export AUTHORIZATION_HEADER_FILE k6 run --quiet --no-thresholds --summary-mode disabled "${pass_through[@]}" "$script" overall_status=0 @@ -239,27 +252,21 @@ fi result_dir="$run_dir/results/$stamp-$profile" case "$profile" in steady) - export OPS="${OPS:-50}" DURATION="${DURATION:-10m}" + export OPS="${OPS:-50}" DURATION="${DURATION:-3m}" + require_token_window DURATION "$DURATION" run_one "$result_dir" steady "$script" \ "offeredOps=$OPS" "duration=$DURATION" "followCursor=$FOLLOW_CURSOR" "randomSeed=$RANDOM_SEED" ;; burst) export OPS="${OPS:-50}" PEAK_OPS="${PEAK_OPS:-250}" - export BASELINE_DURATION="${BASELINE_DURATION:-2m}" RAMP_DURATION="${RAMP_DURATION:-30s}" - export PEAK_DURATION="${PEAK_DURATION:-30s}" RECOVERY_DURATION="${RECOVERY_DURATION:-3m}" + export BASELINE_DURATION="${BASELINE_DURATION:-30s}" RAMP_DURATION="${RAMP_DURATION:-15s}" + export PEAK_DURATION="${PEAK_DURATION:-30s}" RECOVERY_DURATION="${RECOVERY_DURATION:-90s}" + require_token_window 'burst phase schedule' "$BASELINE_DURATION" "$RAMP_DURATION" "$PEAK_DURATION" "$RAMP_DURATION" "$RECOVERY_DURATION" run_one "$result_dir" burst "$script" \ "baselineOps=$OPS" "peakOps=$PEAK_OPS" "baselineDuration=$BASELINE_DURATION" \ "rampDuration=$RAMP_DURATION" "peakDuration=$PEAK_DURATION" \ "recoveryDuration=$RECOVERY_DURATION" "followCursor=$FOLLOW_CURSOR" "randomSeed=$RANDOM_SEED" ;; - herd) - export VUS="${VUS:-200}" DURATION="${DURATION:-30s}" - run_one "$result_dir" herd "$script" "vus=$VUS" "duration=$DURATION" - ;; - token-soak) - export VUS="${VUS:-200}" DURATION="${DURATION:-1m}" - run_one "$result_dir" token-soak "$script" "vus=$VUS" "duration=$DURATION" - ;; cursor-smoke) export FOLLOW_CURSOR=1 run_one "$result_dir" cursor-smoke "$script" "followCursor=1" "randomSeed=$RANDOM_SEED" diff --git a/products/breg/loadtest/seed.py b/products/breg/loadtest/seed.py index d267792ff3..a863bd8681 100755 --- a/products/breg/loadtest/seed.py +++ b/products/breg/loadtest/seed.py @@ -18,12 +18,12 @@ import argparse import json import random +import stat import subprocess import sys import threading import time import urllib.error -import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -49,52 +49,60 @@ "Distribution", "Refrigeration", "Textiles", "Components", "Bottling", "Grading", ] BATCH_ITEMS = 100 -TOKEN_REFRESH_MARGIN_SECONDS = 60 +HEADER_REFRESH_SECONDS = 210 class SeedError(RuntimeError): pass -class TokenSource: - """client_secret_post token acquisition with expiry tracking.""" +class DevTokenSource: + """Fresh owner-only headers acquired through the stock dev lifecycle.""" - def __init__(self, token_url: str, client_id: str, secret: str) -> None: - self._token_url = token_url + def __init__(self, helper: Path, bregctl: Path, project: Path, client_id: str) -> None: + self._helper = helper + self._bregctl = bregctl + self._project = project self._client_id = client_id - self._secret = secret self._lock = threading.Lock() - self._token = "" + self._authorization = "" self._expires_at = 0.0 - def token(self) -> str: + def authorization(self) -> str: with self._lock: if time.monotonic() >= self._expires_at: self._refresh() - return self._token + return self._authorization def _refresh(self) -> None: - body = urllib.parse.urlencode( - { - "grant_type": "client_credentials", - "client_id": self._client_id, - "client_secret": self._secret, - } - ).encode("ascii") - request = urllib.request.Request( - self._token_url, - data=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - method="POST", - ) - with urllib.request.urlopen(request, timeout=30) as response: - document = json.loads(response.read().decode("utf-8")) - token = document.get("access_token") - lifetime = document.get("expires_in") - if not isinstance(token, str) or not isinstance(lifetime, (int, float)): - raise SeedError("Mint did not return an access token with expires_in") - self._token = token - self._expires_at = time.monotonic() + float(lifetime) - TOKEN_REFRESH_MARGIN_SECONDS + try: + result = subprocess.run( + [ + sys.executable, + str(self._helper), + "header", + "--bregctl", + str(self._bregctl), + "--project", + str(self._project), + "--client", + self._client_id, + ], + check=True, + capture_output=True, + text=True, + timeout=45, + ) + path = Path(result.stdout.strip()) + if path.is_symlink() or not path.is_file() or stat.S_IMODE(path.stat().st_mode) & 0o077: + raise SeedError("bregctl dev token header is not an owner-only regular file") + value = path.read_text(encoding="ascii").strip() + except (OSError, subprocess.SubprocessError, UnicodeError) as error: + raise SeedError("could not acquire a fresh bregctl dev token") from error + if not value.startswith("Authorization: Bearer ") or value.count(".") != 2: + raise SeedError("bregctl dev token header is malformed") + self._authorization = value.split(": ", 1)[1] + self._expires_at = time.monotonic() + HEADER_REFRESH_SECONDS def canonical_body(items: list[dict[str, Any]]) -> bytes: @@ -109,7 +117,7 @@ def post_batch( breg_url: str, route: str, items: list[dict[str, Any]], - tokens: TokenSource, + tokens: DevTokenSource, idempotency_suffix: str, ) -> list[dict[str, Any]]: body = canonical_body(items) @@ -117,7 +125,7 @@ def post_batch( f"{breg_url}/v1/records/{route}:batch?accessProfile=business-operator", data=body, headers={ - "Authorization": f"Bearer {tokens.token()}", + "Authorization": tokens.authorization(), "Content-Type": "application/json", "Idempotency-Key": f"loadtest-seed-{route}-{idempotency_suffix}", }, @@ -212,7 +220,7 @@ def seed_entity( breg_url: str, route: str, records: list[dict[str, Any]], - tokens: TokenSource, + tokens: DevTokenSource, workers: int, label: str, ) -> list[str]: @@ -262,8 +270,21 @@ def main() -> int: print(f"no load-test environment at {env_path}; run up.sh first", file=sys.stderr) return 2 environment = json.loads(env_path.read_text(encoding="utf-8")) - secret = (arguments.run_dir / "secrets/driver-client-secret").read_text(encoding="ascii").strip() - tokens = TokenSource(environment["token_url"], environment["driver_client_id"], secret) + repository = Path(__file__).resolve().parents[3] + expected_bregctl = repository / "target/debug/bregctl" + expected_project = (arguments.run_dir / "project").resolve() + if Path(environment.get("bregctl", "")).resolve() != expected_bregctl or Path( + environment.get("project", "") + ).resolve() != expected_project: + print("load-test environment references paths outside its owned checkout state", file=sys.stderr) + return 2 + helper = Path(__file__).resolve().parent / "support/loadenv.py" + tokens = DevTokenSource( + helper, + Path(environment["bregctl"]), + Path(environment["project"]), + "loadtest-driver", + ) seed_dir = arguments.run_dir / "seed" seed_dir.mkdir(parents=True, exist_ok=True) seed_summary = seed_dir / "seed-summary.json" diff --git a/products/breg/loadtest/support/evidence.py b/products/breg/loadtest/support/evidence.py index 700c1fd7fd..dcd8a200d5 100755 --- a/products/breg/loadtest/support/evidence.py +++ b/products/breg/loadtest/support/evidence.py @@ -11,8 +11,6 @@ import re import subprocess import sys -import time -import urllib.request from collections import defaultdict from datetime import datetime, timezone from pathlib import Path @@ -31,7 +29,6 @@ "randomSeed", "rateOps", "recoveryDuration", - "vus", "warmupDuration", "warmupOps", } @@ -44,11 +41,6 @@ "business_id_count", } SAFE_SAMPLE_TAGS = {"status", "method", "name", "scenario", "expected_response"} -SAFE_METRIC_LABELS = {"route", "method", "status", "state", "le"} -PROMETHEUS_LINE = re.compile( - r'^(?P[a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{(?P.*)\})?\s+(?P[-+0-9.eE]+|NaN|Inf|-Inf)$' -) -PROMETHEUS_LABEL = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="((?:\\.|[^"\\])*)"') JWT_PATTERN = re.compile(rb"[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}") SQL_TEXT_PATTERN = re.compile( rb"\b(?:SELECT\s+.+\s+FROM|INSERT\s+INTO|UPDATE\s+.+\s+SET|DELETE\s+FROM)\b", @@ -165,67 +157,6 @@ def finish_manifest(arguments: argparse.Namespace) -> None: _write_json(arguments.path, manifest) -def _parse_prometheus(body: str) -> list[dict[str, Any]]: - metrics = [] - for line in body.splitlines(): - if not line or line.startswith("#"): - continue - match = PROMETHEUS_LINE.match(line) - if not match or not match.group("name").startswith("breg_"): - continue - labels: dict[str, str] = {} - raw_labels = match.group("labels") or "" - for label in PROMETHEUS_LABEL.finditer(raw_labels): - name = label.group(1) - if name not in SAFE_METRIC_LABELS: - raise EvidenceError(f"metrics endpoint exposed unexpected label {name}") - labels[name] = bytes(label.group(2), "utf-8").decode("unicode_escape") - metrics.append({"name": match.group("name"), "labels": labels, "value": float(match.group("value"))}) - return metrics - - -def _process_sample(pid_file: Path) -> dict[str, Any] | None: - try: - pid_text = pid_file.read_text(encoding="ascii").strip() - if not pid_text.isdigit(): - return None - output = subprocess.run( - ["ps", "-p", pid_text, "-o", "%cpu=", "-o", "rss="], - check=True, - capture_output=True, - text=True, - timeout=5, - ).stdout.strip() - cpu, rss = output.split() - return {"cpuPercent": float(cpu), "rssBytes": int(rss) * 1024} - except (OSError, ValueError, subprocess.SubprocessError): - return None - - -def sample_metrics(arguments: argparse.Namespace) -> None: - arguments.out.parent.mkdir(parents=True, exist_ok=True) - with arguments.out.open("a", encoding="utf-8", buffering=1) as handle: - while True: - try: - with urllib.request.urlopen(arguments.url, timeout=5) as response: - body = response.read().decode("utf-8") - sample: dict[str, Any] = {"timestamp": _utc_now(), "metrics": _parse_prometheus(body)} - processes = { - name: reading - for name, path in (("server", arguments.breg_pid), ("mint", arguments.mint_pid)) - if path is not None and (reading := _process_sample(path)) is not None - } - if processes: - sample["processes"] = processes - handle.write(json.dumps(sample, sort_keys=True, separators=(",", ":")) + "\n") - except (OSError, UnicodeError, EvidenceError) as error: - handle.write( - json.dumps({"timestamp": _utc_now(), "sampleError": type(error).__name__}, sort_keys=True) - + "\n" - ) - time.sleep(arguments.interval) - - def _metric_values(summary: dict[str, Any], name: str) -> dict[str, Any]: metric = summary.get("metrics", {}).get(name, {}) values = metric.get("values", {}) if isinstance(metric, dict) else {} @@ -317,41 +248,6 @@ def _sample_summary(path: Path) -> dict[str, Any]: } -def _telemetry_summary(path: Path) -> dict[str, Any]: - waiting_peak = 0.0 - cpu_peak: dict[str, float] = defaultdict(float) - rss_peak: dict[str, int] = defaultdict(int) - samples = 0 - errors = 0 - if not path.exists(): - return {"samples": 0, "sampleErrors": 0, "poolWaitingPeak": None, "processes": {}} - with path.open(encoding="utf-8") as handle: - for line in handle: - item = json.loads(line) - if "sampleError" in item: - errors += 1 - continue - samples += 1 - for metric in item.get("metrics", []): - if ( - metric.get("name") == "breg_pool_connections" - and metric.get("labels", {}).get("state") == "waiting" - ): - waiting_peak = max(waiting_peak, float(metric["value"])) - for name, process in item.get("processes", {}).items(): - cpu_peak[name] = max(cpu_peak[name], float(process["cpuPercent"])) - rss_peak[name] = max(rss_peak[name], int(process["rssBytes"])) - return { - "samples": samples, - "sampleErrors": errors, - "poolWaitingPeak": waiting_peak if samples else None, - "processes": { - name: {"cpuPercentPeak": cpu_peak[name], "rssBytesPeak": rss_peak[name]} - for name in sorted(set(cpu_peak) | set(rss_peak)) - }, - } - - def _db_wait_summary(path: Path) -> dict[str, Any]: peaks = {"auditLockWaiters": 0, "lockWaiters": 0, "blockedBackends": 0} samples = 0 @@ -419,7 +315,6 @@ def summarize(arguments: argparse.Namespace) -> None: }, "httpStatuses": sample_details["statuses"], "phases": sample_details["phaseResults"], - "telemetry": _telemetry_summary(arguments.telemetry), "database": {"snapshot": db_after, "waits": _db_wait_summary(arguments.db_waits)}, "thresholds": thresholds, "pass": ( @@ -515,18 +410,10 @@ def parser() -> argparse.ArgumentParser: finish.add_argument("--exit-code", type=int, required=True) finish.add_argument("--k6-exit-code", type=int, required=True) - sample = commands.add_parser("sample-metrics") - sample.add_argument("--url", required=True) - sample.add_argument("--out", type=Path, required=True) - sample.add_argument("--interval", type=float, default=1.0) - sample.add_argument("--server-pid", type=Path) - sample.add_argument("--mint-pid", type=Path) - summary = commands.add_parser("summarize") summary.add_argument("--manifest", type=Path, required=True) summary.add_argument("--k6-summary", type=Path, required=True) summary.add_argument("--samples", type=Path, required=True) - summary.add_argument("--telemetry", type=Path, required=True) summary.add_argument("--db-after", type=Path, required=True) summary.add_argument("--db-waits", type=Path, required=True) summary.add_argument("--safety", type=Path, required=True) @@ -553,8 +440,6 @@ def main() -> int: create_manifest(arguments) elif arguments.command == "finish": finish_manifest(arguments) - elif arguments.command == "sample-metrics": - sample_metrics(arguments) elif arguments.command == "summarize": summarize(arguments) elif arguments.command == "aggregate-sweep": diff --git a/products/breg/loadtest/support/loadenv.py b/products/breg/loadtest/support/loadenv.py index c1d663a2ef..a0da1a0dee 100755 --- a/products/breg/loadtest/support/loadenv.py +++ b/products/breg/loadtest/support/loadenv.py @@ -1,12 +1,10 @@ #!/usr/bin/env python3 -"""Helpers for the Base Registry Engine load-test environment. - -The environment is a self-contained local stack on loopback: pinned -PostgreSQL 17 with TLS and pg_stat_statements, Registry Mint with one -private-key operator client (seeding and schema tests) and one client-secret -driver client (the k6 harness), and Base Registry Engine serving the -business-establishments acceptance fixture with the opt-in metrics listener -enabled. Everything disposable lives under loadtest/.run. +"""Bounded helpers for the BReg load-test development session. + +The stock ``bregctl dev`` lifecycle owns PostgreSQL, ThunderID, package +activation, credentials, and the BReg process. This module prepares only the +authored load-test project and validates the non-secret references that the +load harness consumes. """ from __future__ import annotations @@ -14,36 +12,33 @@ import argparse import json import os +import shutil import socket import stat +import subprocess import sys -import time -import urllib.error -import urllib.parse -import urllib.request from pathlib import Path from typing import Any +from urllib.parse import urlparse -AUDIENCE = "urn:breg:loadtest" -OPERATOR_CLIENT_ID = "loadtest-operator" -NO_PURPOSE_CLIENT_ID = "loadtest-no-purpose" -DRIVER_CLIENT_ID = "loadtest-driver" -DATABASE_ID = "business-establishments-loadtest" -INSTANCE_ID = "business-establishments-loadtest" -SOURCE_REVISION = "business-establishments-loadtest-0.1.0" -RUNTIME_DATABASE = "business_loadtest" -TEST_DATABASE = "business_loadtest_test" -MIGRATION_ROLE = "registry_loadtest_migration" -RUNTIME_ROLE = "registry_loadtest_runtime" -TOKEN_LIFETIME_SECONDS = 900 -DEFAULT_POOL_MAX = 32 - +CLIENT_ID = "loadtest-driver" +DATABASE_NAME = "breg_dev" +DEV_POOL_MAX = 4 PROJECT_REPLACEMENTS = { " environment: acceptance": " environment: local", - " instanceId: business-establishments-acceptance": f" instanceId: {INSTANCE_ID}", - " sourceRevision: business-establishments-acceptance-0.1.0": f" sourceRevision: {SOURCE_REVISION}", + " instanceId: business-establishments-acceptance": " instanceId: business-establishments-loadtest", + " sourceRevision: business-establishments-acceptance-0.1.0": " sourceRevision: business-establishments-loadtest-0.1.0", } +DEV_CLIENTS = """version: 1 +clients: + - id: loadtest-driver + accessProfiles: [business-operator] + scopes: [registry:business:operate] + claims: + registry_principal: synthetic-business-operator + registry_purpose: business-administration +""" class LoadtestError(RuntimeError): @@ -55,11 +50,6 @@ def _write_new(path: Path, content: str, mode: int = 0o600) -> None: descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) with os.fdopen(descriptor, "w", encoding="utf-8") as handle: handle.write(content) - path.chmod(mode) - - -def _write_json(path: Path, value: Any, mode: int = 0o600) -> None: - _write_new(path, json.dumps(value, sort_keys=True, separators=(",", ":")), mode) def _read_json_object(path: Path) -> dict[str, Any]: @@ -69,25 +59,30 @@ def _read_json_object(path: Path) -> dict[str, Any]: return value +def _owner_only_regular(path: Path, label: str) -> Path: + if path.is_symlink() or not path.is_file(): + raise LoadtestError(f"{label} must be an owner-only regular file") + if stat.S_IMODE(path.stat().st_mode) & 0o077: + raise LoadtestError(f"{label} must be an owner-only regular file") + return path.resolve() + + def _require_root(root: Path) -> Path: if root.is_symlink(): raise LoadtestError("load-test run directory must not be a symbolic link") resolved = root.resolve() if not resolved.is_dir(): raise LoadtestError("load-test run directory must be an existing ordinary directory") + marker = resolved / ".launcher-owned" + if marker.read_text(encoding="ascii").strip() != "registry-stack-breg-loadtest-v2": + raise LoadtestError("load-test run directory has no matching ownership marker") return resolved -def _replace_once(source: str, expected: str, replacement: str, message: str) -> str: - if source.count(expected) != 1: - raise LoadtestError(message) - return source.replace(expected, replacement, 1) - - -def reserve_ports() -> tuple[int, int, int, int]: +def reserve_ports() -> tuple[int, int, int]: listeners: list[socket.socket] = [] try: - for _ in range(4): + for _ in range(3): listener = socket.socket() listener.bind(("127.0.0.1", 0)) listeners.append(listener) @@ -97,411 +92,115 @@ def reserve_ports() -> tuple[int, int, int, int]: listener.close() -def _render_claims(claims: dict[str, Any]) -> str: - return "".join( - f" {name}: {json.dumps(value, ensure_ascii=True, separators=(',', ':'))}\n" - for name, value in sorted(claims.items()) - ) - - -def _private_key_client( - client_id: str, - public_key: dict[str, Any], - scopes: list[str], - claims: dict[str, Any], -) -> str: - return ( - f"clientId: {client_id}\n" - f"principal: urn:breg:loadtest:{client_id}\n" - "authorization:\n" - f" scopes: {json.dumps(scopes, separators=(',', ':'))}\n" - " claims:\n" - f"{_render_claims(claims)}" - f"keys: [{json.dumps(public_key, sort_keys=True, separators=(',', ':'))}]\n" - ) - - -def _client_secret_client(fingerprint: str) -> str: - if not fingerprint or any(character.isspace() for character in fingerprint): - raise LoadtestError("driver client secret fingerprint must be one non-empty token") - return ( - f"clientId: {DRIVER_CLIENT_ID}\n" - f"principal: urn:breg:loadtest:{DRIVER_CLIENT_ID}\n" - "authorization:\n" - ' scopes: ["registry:business:operate"]\n' - " claims:\n" - " registry_principal: synthetic-loadtest-driver\n" - " registry_purpose: business-administration\n" - "clientAuthentication:\n" - " method: client-secret\n" - f" secretFingerprints: [{json.dumps(fingerprint)}]\n" - ) - - -def operator_clients(public_key: dict[str, Any]) -> dict[str, str]: - # The fixture journeys pin the synthetic-business-operator principal, so - # the schema-test clients must carry exactly these claims. - return { - OPERATOR_CLIENT_ID: _private_key_client( - OPERATOR_CLIENT_ID, - public_key, - ["registry:business:operate"], - { - "registry_principal": "synthetic-business-operator", - "registry_purpose": "business-administration", - }, - ), - NO_PURPOSE_CLIENT_ID: _private_key_client( - NO_PURPOSE_CLIENT_ID, - public_key, - ["registry:business:operate"], - {"registry_principal": "synthetic-business-operator"}, - ), - } - - -def _runtime_template( - root: Path, - revision: str, - package_root: Path, - database: str, - pool_max: int, -) -> str: - origin = urllib.parse.urlparse((root / "breg-origin").read_text(encoding="ascii").strip()) - mint_origin = (root / "mint-origin").read_text(encoding="ascii").strip() - metrics_port = int((root / "metrics-port").read_text(encoding="ascii").strip()) - if origin.scheme != "http" or origin.hostname != "127.0.0.1" or origin.port is None: - raise LoadtestError("server origin must be exact loopback HTTP") - if not revision.startswith("sha256:") or len(revision) != 71: - raise LoadtestError("package revision must be one SHA-256 identifier") - if not 1 <= pool_max <= 128: - raise LoadtestError("pool maxSize must be between 1 and 128") - return f"""apiVersion: registry.registrystack.org/breg-runtime/v1alpha1 -kind: BRegRuntimeConfig -listener: - bind: 127.0.0.1:{origin.port} -identity: - environment: local - instanceId: {INSTANCE_ID} - databaseId: {DATABASE_ID} - databaseInitializationEnvironment: local -secretProviders: - file: - root: {root / 'secrets'} -database: - runtimeUrlRef: secret:file/{database}-runtime-database-url - migrationUrlRef: secret:file/{database}-migration-database-url - pool: - maxSize: {pool_max} - roles: - migration: {MIGRATION_ROLE} - runtime: {RUNTIME_ROLE} -package: - root: {package_root} - trustAnchorPath: {root / 'trust-anchor.json'} - compilerSourceRevision: {SOURCE_REVISION} - activeRevision: {revision} - activeSequence: 1 -authentication: - oidc: - issuer: {mint_origin} - audience: {AUDIENCE} - allowedAlgorithm: ES256 - accessTokenType: at+jwt - scopeClaim: scope - scopeSeparator: " " - allowedClients: [{OPERATOR_CLIENT_ID}, {NO_PURPOSE_CLIENT_ID}, {DRIVER_CLIENT_ID}] - deniedKids: [] - maxTokenLifetimeSeconds: {TOKEN_LIFETIME_SECONDS} - leewayMilliseconds: 30000 - jwksSource: - kind: static - documentRef: secret:file/mint-jwks - authorityClaims: - principal: registry_principal - purpose: registry_purpose -audit: - hashKeyRef: secret:file/audit-key -cursor: - secretRef: secret:file/cursor-key -eventDestinations: {{}} -metricsListener: - bind: 127.0.0.1:{metrics_port} -""" +def _replace_once(source: str, expected: str, replacement: str) -> str: + if source.count(expected) != 1: + raise LoadtestError(f"fixture no longer has the expected line: {expected.strip()}") + return source.replace(expected, replacement, 1) -def _initialize_sql(database: str) -> str: - statements = [ - "CREATE EXTENSION IF NOT EXISTS btree_gist;", - "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;", - f"REVOKE ALL ON DATABASE {database} FROM PUBLIC;", - f"GRANT CONNECT ON DATABASE {database} TO {MIGRATION_ROLE}, {RUNTIME_ROLE};", - f"CREATE SCHEMA registry_internal AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_data AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_source AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_derived AUTHORIZATION {MIGRATION_ROLE};", - f"CREATE SCHEMA registry_context AUTHORIZATION {MIGRATION_ROLE};", - "REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC;", - ] - return "\n".join(statements) + "\n" - - -SCHEMA_TEST_CREDENTIALS = """apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1 -kind: SchemaTestCredentials -bindings: - - {journeyId: business-establishment-lifecycle, stepId: create-north-head-office, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-production-branch, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-central-head-office, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-central-branch, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-central-depot, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-isolation-head-office, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-isolation-regional-office, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-isolation-branch, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-north-business, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-central-business, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: create-isolation-business, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: lookup-north-business, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: read-establishments-from-north-business, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: query-establishment-summary, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: refuse-incomplete-assignment, credential: {type: bearer, tokenRef: secret:file/operator-token}} - - {journeyId: business-establishment-lifecycle, stepId: operator-without-purpose-is-concealed, credential: {type: bearer, tokenRef: secret:file/no-purpose-token}} -""" +def _without_nonrepresentative_journey(source: str) -> str: + """Drop the alternate-claims case that cannot share a dev profile client.""" + kept: list[str] = [] + skipping = False + found = False + for line in source.splitlines(keepends=True): + if line.startswith(" - id:"): + skipping = "operator-without-purpose-is-concealed" in line + found = found or skipping + if not skipping: + kept.append(line) + if not found: + raise LoadtestError("fixture no longer has the expected no-purpose journey") + return "".join(kept) def local_project(fixture: Path, project: Path) -> None: if fixture.is_symlink() or not fixture.is_dir(): raise LoadtestError("business-establishments fixture must be an ordinary directory") - if project.exists(): + if any(path.is_symlink() for path in fixture.rglob("*")): + raise LoadtestError("business-establishments fixture must not contain symbolic links") + if project.exists() or project.is_symlink(): raise LoadtestError("load-test project output must not already exist") - import shutil - shutil.copytree(fixture, project, symlinks=False) - path = project / "registry.yaml" - source = path.read_text(encoding="utf-8") + registry = project / "registry.yaml" + source = registry.read_text(encoding="utf-8") for expected, replacement in PROJECT_REPLACEMENTS.items(): - source = _replace_once( - source, - expected, - replacement, - f"business-establishments fixture no longer has the expected line: {expected.strip()}", - ) - path.write_text(source, encoding="utf-8") - - -def prepare( - root: Path, - database_port: int, - mint_port: int, - breg_port: int, - metrics_port: int, - driver_secret_fingerprint: str, - pool_max: int, -) -> None: - root = _require_root(root) - if not (root / "project/registry.yaml").is_file(): - raise LoadtestError("local project did not create registry.yaml") - password = (root / "secrets/database-password").read_text(encoding="ascii").strip() - if not password or any(character not in "0123456789abcdef" for character in password): - raise LoadtestError("database password must be non-empty lowercase hexadecimal") - mint_public = _read_json_object(root / "keys/mint-public.jwk.json") - operator_public = _read_json_object(root / "keys/operator-public.jwk.json") - kid = mint_public.get("kid") - if not isinstance(kid, str) or not kid: - raise LoadtestError("Mint public JWK must carry a key identifier") - mint_origin = f"http://127.0.0.1:{mint_port}" - breg_origin = f"http://127.0.0.1:{breg_port}" - _write_new(root / "mint-origin", mint_origin + "\n") - _write_new(root / "breg-origin", breg_origin + "\n") - _write_new(root / "metrics-port", f"{metrics_port}\n") - _write_json(root / "secrets/mint-jwks", {"keys": [mint_public]}, 0o600) - _write_json(root / f"mint/public-keys/{kid}.jwk.json", mint_public) - for client_id, document in operator_clients(operator_public).items(): - _write_new(root / f"mint/clients/{client_id}.yaml", document) - _write_new(root / f"mint/clients/{DRIVER_CLIENT_ID}.yaml", _client_secret_client(driver_secret_fingerprint)) - _write_new( - root / "mint/mint.yaml", - f"""version: 1 -validationMode: supervised-local-development -issuer: {mint_origin} -listener: {{address: 127.0.0.1, port: {mint_port}}} -signing: - algorithm: ES256 - activePublicJwkFile: public-keys/{kid}.jwk.json - publishedPublicJwkFiles: [] - revokedKeyIds: [] -signer: - kind: local-jwk - privateKeyRef: secret:file/signing-p256-private-jwk -secretProviders: - file: {{root: {root / 'keys/mint'}}} -audit: - path: audit/mint.jsonl - maximumFileBytes: 10485760 - hashKeyRef: secret:file/audit-hmac-key - hashKeyVersion: 1 -accessTokens: - audiences: [{AUDIENCE}] - lifetimeSeconds: {TOKEN_LIFETIME_SECONDS} -clientAssertion: - audience: {mint_origin}/token - maximumLifetimeSeconds: 120 - algorithms: [ES256] -clients: - directory: clients -""", + source = _replace_once(source, expected, replacement) + registry.write_text(source, encoding="utf-8") + journeys = project / "tests/journeys.yaml" + journeys.write_text( + _without_nonrepresentative_journey(journeys.read_text(encoding="utf-8")), + encoding="utf-8", ) - encoded_password = urllib.parse.quote(password, safe="") - base = f"localhost:{database_port}" - for database in (RUNTIME_DATABASE, TEST_DATABASE): - _write_new( - root / f"secrets/{database}-runtime-database-url", - f"postgresql://{RUNTIME_ROLE}:{encoded_password}@{base}/{database}", - 0o600, - ) - _write_new( - root / f"secrets/{database}-migration-database-url", - f"postgresql://{MIGRATION_ROLE}:{encoded_password}@{base}/{database}", - 0o600, - ) - _write_new( - root / "database/postgres.env", - f"POSTGRES_USER=postgres\nPOSTGRES_PASSWORD={password}\nPOSTGRES_DB=postgres\n", - 0o600, - ) - _write_new( - root / "database/bootstrap.sql", - f"""CREATE ROLE {MIGRATION_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -CREATE ROLE {RUNTIME_ROLE} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '{password}'; -""", - 0o600, - ) - _write_new(root / "database/initialize-runtime.sql", _initialize_sql(RUNTIME_DATABASE)) - _write_new(root / "database/initialize.sql", _initialize_sql(TEST_DATABASE)) - _write_new(root / "trust-anchor.json", "{}") - _write_new(root / "empty-package/.keep", "") - _write_new( - root / "runtime-test.yaml", - _runtime_template(root, "sha256:" + "1" * 64, root / "empty-package", TEST_DATABASE, pool_max), - ) - _write_new(root / "schema-test-credentials.yaml", SCHEMA_TEST_CREDENTIALS) + (project / "dev-clients.yaml").write_text(DEV_CLIENTS, encoding="utf-8") -def render_runtime(root: Path, revision: str, pool_max: int) -> None: +def write_environment(root: Path, dev_report: Path, bregctl: Path) -> None: root = _require_root(root) - _write_new(root / "runtime.yaml", _runtime_template(root, revision, root / "build/package", RUNTIME_DATABASE, pool_max)) - - -def store_token(path: Path, source: bytes) -> None: - if len(source) > 64 * 1024: - raise LoadtestError("Mint returned an oversized token") - try: - value = source.decode("ascii").rstrip("\r\n") - except UnicodeDecodeError as error: - raise LoadtestError("Mint returned a non-ASCII token") from error - if value.count(".") != 2 or any(character.isspace() for character in value): - raise LoadtestError("Mint did not return one compact JWT") - _write_new(path, value, 0o600) - - -def mint_client_secret_token(url: str, client_id: str, secret_path: Path, out: Path) -> None: - _require_owner_only_regular(secret_path, "driver client secret") - secret = secret_path.read_text(encoding="ascii").strip() - body = urllib.parse.urlencode({"grant_type": "client_credentials", "client_id": client_id, "client_secret": secret}).encode("ascii") - request = urllib.request.Request( - url, - data=body, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - method="POST", + report = _read_json_object(dev_report) + project = (root / "project").resolve() + if Path(report.get("project", "")).resolve() != project or report.get("status") != "ready": + raise LoadtestError("bregctl dev did not report the expected ready project") + expected_state = project / ".breg/dev/state.json" + if Path(report.get("stateFile", "")).resolve() != expected_state.resolve(): + raise LoadtestError("bregctl dev reported state outside the load-test project") + state = _read_json_object(_owner_only_regular(expected_state, "development state")) + container = state.get("containerId") + if not isinstance(container, str) or len(container) != 64 or not all(c in "0123456789abcdefABCDEF" for c in container): + raise LoadtestError("development state has no bounded database container identity") + origin = urlparse(str(report.get("bregUrl", ""))) + if origin.scheme != "http" or origin.hostname != "127.0.0.1" or origin.port is None: + raise LoadtestError("bregctl dev reported a non-loopback BReg origin") + executable = bregctl.resolve() + if not executable.is_file(): + raise LoadtestError("bregctl path must be a regular file") + environment = { + "breg_url": report["bregUrl"], + "bregctl": str(executable), + "project": str(project), + "pool_max": DEV_POOL_MAX, + "database": {"container": container, "database": DATABASE_NAME}, + } + _write_new(root / "env.json", json.dumps(environment, indent=2, sort_keys=True) + "\n") + + +def fresh_header(bregctl: Path, project: Path, client: str) -> Path: + if client != CLIENT_ID: + raise LoadtestError("the load-test client is not registered") + result = subprocess.run( + [str(bregctl), "--format", "json", "dev", "token", client, str(project)], + check=True, + capture_output=True, + text=True, + timeout=45, ) - with urllib.request.urlopen(request, timeout=30) as response: - document = json.loads(response.read().decode("utf-8")) - token = document.get("access_token") - if not isinstance(token, str) or token.count(".") != 2: - raise LoadtestError("Mint did not return one compact access token") - store_token(out, token.encode("ascii")) - - -def _require_owner_only_regular(path: Path, name: str) -> None: - if not path.is_file() or path.is_symlink(): - raise LoadtestError(f"{name} must be an owner-only regular file") - if stat.S_IMODE(path.stat().st_mode) & 0o077: - raise LoadtestError(f"{name} must be an owner-only regular file") - - -def wait_http(url: str, timeout_seconds: float) -> None: - deadline = time.monotonic() + timeout_seconds - last: int | None = None - while time.monotonic() < deadline: - try: - with urllib.request.urlopen(url, timeout=2) as response: - last = response.status - except urllib.error.HTTPError as error: - last = error.code - except urllib.error.URLError: - last = None - if last is not None and 200 <= last < 400: - return - time.sleep(0.25) - raise LoadtestError(f"{url} did not become ready (last status {last})") - - -def json_field(path: Path, field: str) -> None: - value = json.loads(path.read_text(encoding="utf-8")) - print(value[field]) - - -def scrape(url: str, out: Path) -> None: - with urllib.request.urlopen(url, timeout=10) as response: - body = response.read().decode("utf-8") - out.parent.mkdir(parents=True, exist_ok=True) - out.write_text(body, encoding="utf-8") + report = json.loads(result.stdout) + expected = project.resolve() / f".breg/dev/secrets/{client}.header" + reported = Path(report.get("headerFile", "")).resolve() + if reported != expected: + raise LoadtestError("bregctl dev token reported a header outside the owned session") + _owner_only_regular(reported, "authorization header") + value = reported.read_text(encoding="ascii") + if not value.startswith("Authorization: Bearer ") or value.count(".") != 2 or not value.endswith("\n"): + raise LoadtestError("bregctl dev token returned a malformed authorization header") + return reported def parser() -> argparse.ArgumentParser: - result = argparse.ArgumentParser(description="Base Registry Engine load-test environment helpers") - subcommands = result.add_subparsers(dest="command", required=True) - - subcommands.add_parser("ports") - - local = subcommands.add_parser("local-project") + result = argparse.ArgumentParser(description=__doc__) + commands = result.add_subparsers(dest="command", required=True) + commands.add_parser("ports") + local = commands.add_parser("local-project") local.add_argument("--fixture", type=Path, required=True) local.add_argument("--project", type=Path, required=True) - - prepare_command = subcommands.add_parser("prepare") - prepare_command.add_argument("--root", type=Path, required=True) - prepare_command.add_argument("--database-port", type=int, required=True) - prepare_command.add_argument("--mint-port", type=int, required=True) - prepare_command.add_argument("--breg-port", type=int, required=True) - prepare_command.add_argument("--metrics-port", type=int, required=True) - prepare_command.add_argument("--driver-secret-fingerprint", required=True) - prepare_command.add_argument("--pool-max", type=int, default=DEFAULT_POOL_MAX) - - render = subcommands.add_parser("render-runtime") - render.add_argument("--root", type=Path, required=True) - render.add_argument("--revision", required=True) - render.add_argument("--pool-max", type=int, default=DEFAULT_POOL_MAX) - - store = subcommands.add_parser("store-token") - store.add_argument("--out", type=Path, required=True) - - mint = subcommands.add_parser("mint-client-secret-token") - mint.add_argument("--url", required=True) - mint.add_argument("--client-id", required=True) - mint.add_argument("--secret", type=Path, required=True) - mint.add_argument("--out", type=Path, required=True) - - wait = subcommands.add_parser("wait-http") - wait.add_argument("--url", required=True) - wait.add_argument("--timeout", type=float, default=30) - - field = subcommands.add_parser("json-field") - field.add_argument("--path", type=Path, required=True) - field.add_argument("--field", required=True) - - scrape_command = subcommands.add_parser("scrape") - scrape_command.add_argument("--url", required=True) - scrape_command.add_argument("--out", type=Path, required=True) - + environment = commands.add_parser("environment") + environment.add_argument("--root", type=Path, required=True) + environment.add_argument("--dev-report", type=Path, required=True) + environment.add_argument("--bregctl", type=Path, required=True) + header = commands.add_parser("header") + header.add_argument("--bregctl", type=Path, required=True) + header.add_argument("--project", type=Path, required=True) + header.add_argument("--client", default=CLIENT_ID) return result @@ -512,29 +211,17 @@ def main() -> int: print(" ".join(str(port) for port in reserve_ports())) elif arguments.command == "local-project": local_project(arguments.fixture, arguments.project) - elif arguments.command == "prepare": - prepare( - arguments.root, - arguments.database_port, - arguments.mint_port, - arguments.breg_port, - arguments.metrics_port, - arguments.driver_secret_fingerprint, - arguments.pool_max, - ) - elif arguments.command == "render-runtime": - render_runtime(arguments.root, arguments.revision, arguments.pool_max) - elif arguments.command == "store-token": - store_token(arguments.out, sys.stdin.buffer.read()) - elif arguments.command == "mint-client-secret-token": - mint_client_secret_token(arguments.url, arguments.client_id, arguments.secret, arguments.out) - elif arguments.command == "wait-http": - wait_http(arguments.url, arguments.timeout) - elif arguments.command == "json-field": - json_field(arguments.path, arguments.field) - elif arguments.command == "scrape": - scrape(arguments.url, arguments.out) - except (LoadtestError, OSError, json.JSONDecodeError, KeyError) as error: + elif arguments.command == "environment": + write_environment(arguments.root, arguments.dev_report, arguments.bregctl) + elif arguments.command == "header": + print(fresh_header(arguments.bregctl, arguments.project, arguments.client)) + except ( + LoadtestError, + OSError, + UnicodeError, + ValueError, + subprocess.SubprocessError, + ) as error: print(f"load-test environment error: {error}", file=sys.stderr) return 1 return 0 diff --git a/products/breg/loadtest/support/test_evidence.py b/products/breg/loadtest/support/test_evidence.py index 73bd1184ca..9d4641cf12 100644 --- a/products/breg/loadtest/support/test_evidence.py +++ b/products/breg/loadtest/support/test_evidence.py @@ -19,6 +19,10 @@ assert SPEC and SPEC.loader evidence = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(evidence) +LOADENV_SPEC = importlib.util.spec_from_file_location("loadtest_environment", MODULE_PATH.with_name("loadenv.py")) +assert LOADENV_SPEC and LOADENV_SPEC.loader +loadenv = importlib.util.module_from_spec(LOADENV_SPEC) +LOADENV_SPEC.loader.exec_module(loadenv) class EvidenceTests(unittest.TestCase): @@ -36,7 +40,7 @@ def test_manifest_keeps_only_whitelisted_non_secret_context(self) -> None: { "pool_max": 32, "breg_url": "http://secret-host", - "driver_secret": "/secret/path", + "private_setting": "/secret/path", } ), encoding="utf-8", @@ -108,7 +112,6 @@ def test_summary_reports_operations_http_rate_latency_and_wait_peaks(self) -> No manifest = root / "manifest.json" k6_summary = root / "summary.json" samples = root / "samples.json" - telemetry = root / "telemetry.jsonl" db_after = root / "db-after.json" db_waits = root / "db-waits.jsonl" safety = root / "safety.json" @@ -163,19 +166,6 @@ def test_summary_reports_operations_http_rate_latency_and_wait_peaks(self) -> No for status in ("200", "200", "504") ] samples.write_text("".join(json.dumps(item) + "\n" for item in sample_items), encoding="utf-8") - telemetry.write_text( - json.dumps( - { - "timestamp": "now", - "metrics": [ - {"name": "breg_pool_connections", "labels": {"state": "waiting"}, "value": 3} - ], - "processes": {"server": {"cpuPercent": 12.5, "rssBytes": 4096}}, - } - ) - + "\n", - encoding="utf-8", - ) db_after.write_text(json.dumps({"auditRows": 10}), encoding="utf-8") db_waits.write_text( json.dumps({"auditLockWaiters": 2, "lockWaiters": 3, "blockedBackends": 1}) + "\n", @@ -187,7 +177,6 @@ def test_summary_reports_operations_http_rate_latency_and_wait_peaks(self) -> No manifest=manifest, k6_summary=k6_summary, samples=samples, - telemetry=telemetry, db_after=db_after, db_waits=db_waits, safety=safety, @@ -202,16 +191,14 @@ def test_summary_reports_operations_http_rate_latency_and_wait_peaks(self) -> No self.assertEqual(result["latency"]["byOperation"]["get"]["p95Ms"], 38.5) self.assertEqual(result["phases"]["steady"]["httpRequests"], 3) self.assertEqual(result["phases"]["steady"]["timeouts504"], 1) - self.assertEqual(result["telemetry"]["poolWaitingPeak"], 3) self.assertEqual(result["database"]["waits"]["auditLockWaitersPeak"], 2) self.assertTrue(result["pass"]) - def test_profiles_pin_held_sweep_burst_recovery_and_one_shot_herd(self) -> None: + def test_profiles_pin_held_sweep_and_burst_recovery(self) -> None: loadtest = MODULE_PATH.parent.parent workload = (loadtest / "lib/workload.js").read_text(encoding="utf-8") sweep = (loadtest / "profiles/sweep.js").read_text(encoding="utf-8") burst = (loadtest / "profiles/burst.js").read_text(encoding="utf-8") - herd = (loadtest / "profiles/herd.js").read_text(encoding="utf-8") runner = (loadtest / "run.sh").read_text(encoding="utf-8") self.assertIn("body.pageInfo.nextCursor", workload) self.assertIn("$skiptoken=", workload) @@ -219,9 +206,11 @@ def test_profiles_pin_held_sweep_burst_recovery_and_one_shot_herd(self) -> None: self.assertNotIn("ramping-arrival-rate", sweep) self.assertIn("recovery:", burst) self.assertIn("'http_req_failed{scenario:recovery}'", burst) - self.assertIn("executor: 'per-vu-iterations'", herd) + self.assertNotIn("token-soak", runner) + self.assertNotIn("herd", runner) + self.assertIn("dev token", runner) self.assertIn("--http-debug|--http-debug=*|--system-tags|--system-tags=*", runner) - for profile in (sweep, burst, herd): + for profile in (sweep, burst): self.assertNotIn("sleep(", profile) @unittest.skipUnless(shutil.which("k6"), "k6 is not installed") @@ -259,11 +248,12 @@ def log_message(self, _format: str, *args: object) -> None: artifacts = root / "artifacts" artifacts.mkdir() seed = root / "ids.txt" - secret = root / "secret.txt" + secret = root / "operator.header" samples = artifacts / "samples.json" summary = artifacts / "summary.json" seed.write_text("record-1 LT-E-1\n", encoding="utf-8") - secret.write_text("unrelated-secret-value", encoding="utf-8") + secret.write_text("Authorization: Bearer header.payload.signature\n", encoding="ascii") + secret.chmod(0o600) origin = f"http://127.0.0.1:{server.server_port}" profile = MODULE_PATH.parent.parent / "profiles/cursor-smoke.js" command = [ @@ -277,11 +267,7 @@ def log_message(self, _format: str, *args: object) -> None: "-e", f"BREG_URL={origin}", "-e", - f"TOKEN_URL={origin}/token", - "-e", - "CLIENT_ID=test", - "-e", - "CLIENT_SECRET=test", + f"AUTHORIZATION_HEADER_FILE={secret}", "-e", "FOLLOW_CURSOR=1", "-e", @@ -314,6 +300,44 @@ def log_message(self, _format: str, *args: object) -> None: self.assertIn("$filter", requests[0]) self.assertEqual(requests[1], {"accessProfile": ["business-operator"], "$skiptoken": ["cursor-value"]}) + def test_load_environment_prepares_one_dev_client_and_preserves_existing_output(self) -> None: + repository = MODULE_PATH.parents[4] + fixture = repository / "products/breg/acceptance/business-establishments" + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) / "project" + loadenv.local_project(fixture, project) + clients = (project / "dev-clients.yaml").read_text(encoding="utf-8") + self.assertIn("id: loadtest-driver", clients) + self.assertIn("accessProfiles: [business-operator]", clients) + self.assertNotIn( + "operator-without-purpose-is-concealed", + (project / "tests/journeys.yaml").read_text(encoding="utf-8"), + ) + sentinel = project / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + with self.assertRaises(loadenv.LoadtestError): + loadenv.local_project(fixture, project) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertIn( + "operator-without-purpose-is-concealed", + (fixture / "tests/journeys.yaml").read_text(encoding="utf-8"), + ) + + def test_dev_header_must_be_the_owned_private_session_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + project = Path(directory) / "project" + header = project / ".breg/dev/secrets/loadtest-driver.header" + header.parent.mkdir(parents=True) + header.write_text("Authorization: Bearer header.payload.signature\n", encoding="ascii") + header.chmod(0o600) + fake = Path(directory) / "bregctl" + fake.write_text(f"#!/bin/sh\nprintf '%s\\n' '{{\"headerFile\":\"{header}\"}}'\n", encoding="utf-8") + fake.chmod(0o700) + self.assertEqual(loadenv.fresh_header(fake, project, "loadtest-driver"), header.resolve()) + header.chmod(0o644) + with self.assertRaises(loadenv.LoadtestError): + loadenv.fresh_header(fake, project, "loadtest-driver") + def test_shell_entrypoints_parse(self) -> None: loadtest = MODULE_PATH.parent.parent subprocess.run( @@ -330,9 +354,9 @@ def test_shell_entrypoints_parse(self) -> None: up = (loadtest / "up.sh").read_text(encoding="utf-8") down = (loadtest / "down.sh").read_text(encoding="utf-8") self.assertIn("trap cleanup_failed_start EXIT", up) - self.assertIn("org.registrystack.loadtest=breg", up) - self.assertIn("^breg-loadtest-[0-9]+-[0-9]+$", down) - self.assertIn('ps -ww -p "$pid" -o command=', down) + self.assertIn("bregctl\" --format json dev start", up) + self.assertIn("dev stop --remove", down) + self.assertNotIn("rm -rf", up + down) if __name__ == "__main__": diff --git a/products/breg/loadtest/up.sh b/products/breg/loadtest/up.sh index 83a153debe..81878fa0bf 100755 --- a/products/breg/loadtest/up.sh +++ b/products/breg/loadtest/up.sh @@ -7,285 +7,74 @@ repository_root=$(cd -- "$product_dir/../.." && pwd) support="$loadtest_dir/support/loadenv.py" run_dir="$loadtest_dir/.run" fixture="$product_dir/acceptance/business-establishments" -mint_key_material="$repository_root/crates/registry-mint/demo/support/key_material.py" -postgres_image='postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675' -pool_max=32 -keep=false -cleanup_failed_start() { - local status=$? - if [[ "$status" -ne 0 && -f "$run_dir/env.json" ]]; then - printf '%s\n' 'Load-test startup failed; stopping resources started by this environment.' >&2 - "$loadtest_dir/down.sh" >/dev/null 2>&1 || - printf '%s\n' "Automatic cleanup failed; inspect $run_dir and run down.sh." >&2 - fi - exit "$status" -} -trap cleanup_failed_start EXIT - -while [[ $# -gt 0 ]]; do - case "$1" in - --pool-max) - if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ ]]; then - printf '%s\n' '--pool-max requires a number.' >&2 - exit 2 - fi - pool_max="$2" - shift 2 - ;; - --keep) - keep=true - shift - ;; - *) - printf '%s\n' "usage: products/breg/loadtest/up.sh [--pool-max N] [--keep]" >&2 - exit 2 - ;; - esac -done - -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - printf '%s\n' "$1 is required for the Base Registry Engine load-test environment." >&2 - exit 2 - fi -} - -for command in cargo docker openssl python3 uv; do - require_command "$command" -done - -if [[ -e "$run_dir/env.json" ]]; then - printf '%s\n' "A load-test environment is already recorded at $run_dir/env.json." >&2 - printf '%s\n' "Run products/breg/loadtest/down.sh before starting another one." >&2 - exit 2 -fi -if [[ -L "$run_dir" ]]; then - printf '%s\n' 'load-test run directory must not be a symbolic link.' >&2 +if [[ $# -ne 0 ]]; then + printf '%s\n' 'usage: products/breg/loadtest/up.sh' >&2 exit 2 fi -if [[ -d "$run_dir" ]]; then - rm -rf -- "$run_dir" -elif [[ -e "$run_dir" ]]; then - printf '%s\n' 'load-test run path exists and is not a directory.' >&2 +for command in cargo docker python3; do + command -v "$command" >/dev/null 2>&1 || { + printf '%s\n' "$command is required for the Base Registry Engine load-test environment." >&2 + exit 2 + } +done +if [[ -L "$run_dir" || -e "$run_dir" ]]; then + printf '%s\n' "load-test state path already exists: $run_dir. Preserve it, or move it aside after stopping its owned dev session." >&2 exit 2 fi -umask 077 -mkdir -m 700 "$run_dir" "$run_dir/secrets" "$run_dir/keys" "$run_dir/logs" "$run_dir/tls" -ports=$(python3 "$support" ports) -read -r database_port mint_port breg_port metrics_port <"$run_dir/.launcher-owned" export CARGO_INCREMENTAL=0 export CARGO_PROFILE_DEV_DEBUG=0 export CARGO_PROFILE_TEST_DEBUG=0 export RUSTC_WRAPPER="${RUSTC_WRAPPER-}" -printf '%s\n' '== Building Base Registry Engine, its CLI, and Mint' +printf '%s\n' '== Building Base Registry Engine and bregctl' cargo build --manifest-path "$repository_root/Cargo.toml" --locked \ -p registry-breg --features registry-breg/runtime \ - -p registry-bregctl \ - -p registry-mint \ - --bins >/dev/null - + -p registry-bregctl --bins >/dev/null breg="$repository_root/target/debug/breg" bregctl="$repository_root/target/debug/bregctl" -mint="$repository_root/target/debug/mint" -container="breg-loadtest-${PPID}-$$" + +cleanup_failed_start() { + local status=$? + if [[ "$status" -ne 0 && -d "$run_dir/project/.breg/dev" ]]; then + printf '%s\n' 'Load-test startup failed; stopping only the project-owned dev session.' >&2 + "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || + printf '%s\n' "Automatic owned-session cleanup failed; inspect $run_dir/project/.breg/dev." >&2 + fi + exit "$status" +} +trap cleanup_failed_start EXIT printf '%s\n' '== Preparing the local business-establishments project' python3 "$support" local-project --fixture "$fixture" --project "$run_dir/project" "$bregctl" --format json check "$run_dir/project" >"$run_dir/check-report.json" -printf '%s\n' '== Generating disposable local keys and configuration' -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/mint/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/mint-public.jwk.json" -uv run --quiet "$mint_key_material" p256 \ - --private-out "$run_dir/keys/operator/signing-p256-private-jwk" \ - --public-out "$run_dir/keys/operator-public.jwk.json" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/keys/mint/audit-hmac-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/audit-key" -uv run --quiet "$mint_key_material" secret-hex \ - --out "$run_dir/secrets/cursor-key" -openssl rand -hex 24 >"$run_dir/secrets/database-password" -chmod 600 "$run_dir/secrets/database-password" - -driver_secret_fingerprint=$("$mint" client-secret generate --out "$run_dir/secrets/driver-client-secret") -chmod 600 "$run_dir/secrets/driver-client-secret" - -python3 "$support" prepare \ - --root "$run_dir" \ +read -r database_port issuer_port breg_port < <(python3 "$support" ports) +printf '%s\n' '== Starting the stock retained BReg development lifecycle' +"$bregctl" --format json dev start \ + --breg-bin "$breg" \ + --docker-bin "$(command -v docker)" \ --database-port "$database_port" \ - --mint-port "$mint_port" \ + --issuer-port "$issuer_port" \ --breg-port "$breg_port" \ - --metrics-port "$metrics_port" \ - --driver-secret-fingerprint "$driver_secret_fingerprint" \ - --pool-max "$pool_max" - -openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 \ - -subj '/CN=Base Registry Engine load-test CA' \ - -keyout "$run_dir/tls/ca.key" -out "$run_dir/tls/ca.pem" >/dev/null 2>&1 -openssl req -new -nodes -newkey rsa:2048 \ - -subj '/CN=localhost' \ - -keyout "$run_dir/tls/server.key" -out "$run_dir/tls/server.csr" >/dev/null 2>&1 -printf '%s\n' 'subjectAltName=DNS:localhost' >"$run_dir/tls/server.ext" -openssl x509 -req -sha256 -days 2 \ - -in "$run_dir/tls/server.csr" \ - -CA "$run_dir/tls/ca.pem" \ - -CAkey "$run_dir/tls/ca.key" \ - -CAcreateserial \ - -extfile "$run_dir/tls/server.ext" \ - -out "$run_dir/tls/server.crt" >/dev/null 2>&1 -chmod 600 "$run_dir/tls/ca.key" "$run_dir/tls/server.key" -chmod 644 "$run_dir/tls/ca.pem" "$run_dir/tls/server.crt" - -# Record every cleanup target before the first external resource starts. If a -# later startup step fails, the EXIT trap can safely drive the ordinary -# teardown path instead of leaving a container or reused PID behind. -python3 - "$run_dir" "$container" "$database_port" "$mint_port" "$breg_port" "$metrics_port" "$pool_max" "$keep" <<'PY' -import json -import sys -from pathlib import Path - -run_dir, container, database_port, mint_port, breg_port, metrics_port, pool_max, keep = sys.argv[1:] -environment = { - "breg_url": f"http://127.0.0.1:{breg_port}", - "metrics_url": f"http://127.0.0.1:{metrics_port}/metrics", - "token_url": f"http://127.0.0.1:{mint_port}/token", - "database": { - "container": container, - "port": database_port, - "user": "registry_loadtest_runtime", - "database": "business_loadtest", - "password_file": str(Path(run_dir) / "secrets/business_loadtest-runtime-database-url"), - }, - "driver_client_id": "loadtest-driver", - "driver_secret": str(Path(run_dir) / "secrets/driver-client-secret"), - "operator_key": str(Path(run_dir) / "keys/operator/signing-p256-private-jwk"), - "pool_max": int(pool_max), - "keep": keep == "True" or keep == "true", - "run_dir": str(Path(run_dir)), -} -(Path(run_dir) / "env.json").write_text(json.dumps(environment, indent=2, sort_keys=True) + "\n", encoding="utf-8") -PY - -printf '%s\n' '== Starting disposable PostgreSQL 17 with TLS and pg_stat_statements' -docker run --detach --name "$container" \ - --label org.registrystack.loadtest=breg \ - --env-file "$run_dir/database/postgres.env" \ - --publish "127.0.0.1:${database_port}:5432" \ - "$postgres_image" \ - -c shared_preload_libraries=pg_stat_statements >"$run_dir/postgres-container-id" - -for attempt in $(seq 1 120); do - if [[ "$(docker exec "$container" cat /proc/1/comm)" == postgres ]] && - docker exec "$container" pg_isready -q -U postgres; then - break - fi - if [[ "$attempt" -eq 120 ]]; then - printf '%s\n' "PostgreSQL did not become ready; see $run_dir/logs." >&2 - docker rm -f "$container" >/dev/null 2>&1 || true - exit 1 - fi - sleep 0.25 -done - -postgres_data_directory=$(docker exec "$container" sh -c 'printf %s "$PGDATA"') -case "$postgres_data_directory" in - /var/lib/postgresql/*) ;; - *) - printf '%s\n' 'PostgreSQL reported an unsafe data directory.' >&2 - docker rm -f "$container" >/dev/null 2>&1 || true - exit 1 - ;; -esac -docker cp "$run_dir/tls/server.crt" "$container:$postgres_data_directory/server.crt" -docker cp "$run_dir/tls/server.key" "$container:$postgres_data_directory/server.key" -docker exec --user root "$container" sh -eu -c ' - chown postgres:postgres "$1/server.crt" "$1/server.key" - chmod 644 "$1/server.crt" - chmod 600 "$1/server.key" - printf "\nssl = on\nssl_cert_file = '\''server.crt'\''\nssl_key_file = '\''server.key'\''\n" >> "$1/postgresql.conf" - sed -i "s/^host /hostssl /" "$1/pg_hba.conf" -' sh "$postgres_data_directory" -docker exec --user postgres "$container" \ - pg_ctl -D "$postgres_data_directory" reload >/dev/null - -docker exec -i "$container" psql -v ON_ERROR_STOP=1 -q -U postgres -d postgres \ - <"$run_dir/database/bootstrap.sql" -docker exec "$container" createdb -U postgres business_loadtest_test -docker exec "$container" createdb -U postgres business_loadtest -docker exec -i "$container" psql -v ON_ERROR_STOP=1 -q -U postgres -d business_loadtest_test \ - <"$run_dir/database/initialize.sql" -docker exec -i "$container" psql -v ON_ERROR_STOP=1 -q -U postgres -d business_loadtest \ - <"$run_dir/database/initialize-runtime.sql" - -printf '%s\n' '== Starting Registry Mint (background)' -nohup "$mint" serve --config "$run_dir/mint/mint.yaml" \ - >"$run_dir/logs/mint.log" 2>&1 & -echo $! >"$run_dir/mint.pid" -python3 "$support" wait-http --url "http://127.0.0.1:${mint_port}/ready" --timeout 30 - -"$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id loadtest-operator \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/operator-token" -"$mint" token \ - --url "http://127.0.0.1:${mint_port}/token" \ - --client-id loadtest-no-purpose \ - --key "$run_dir/keys/operator/signing-p256-private-jwk" | - python3 "$support" store-token --out "$run_dir/secrets/no-purpose-token" - -printf '%s\n' '== Testing, packaging, and activating the local Registry' -export SSL_CERT_FILE="$run_dir/tls/ca.pem" -"$bregctl" --format json test "$run_dir/project" \ - --runtime-config "$run_dir/runtime-test.yaml" \ - --credentials "$run_dir/schema-test-credentials.yaml" \ - --database-id business-establishments-loadtest \ - --output "$run_dir/schema-test-receipt.json" \ - >"$run_dir/test-report.json" -schema_fingerprint=$(python3 "$support" json-field --path "$run_dir/test-report.json" --field schemaFingerprint) - -"$bregctl" --format json package "$run_dir/project" \ - --database-id business-establishments-loadtest \ - --schema-fingerprint "$schema_fingerprint" \ - --test-receipt "$run_dir/schema-test-receipt.json" \ - --output "$run_dir/build" \ - >"$run_dir/package-report.json" -package_revision=$(python3 "$support" json-field --path "$run_dir/package-report.json" --field packageRevision) -python3 "$support" render-runtime --root "$run_dir" --revision "$package_revision" --pool-max "$pool_max" - -"$bregctl" apply \ - --runtime-config "$run_dir/runtime.yaml" \ - --package "$run_dir/build/package" \ - --initial >/dev/null -"$bregctl" verify --runtime-config "$run_dir/runtime.yaml" >/dev/null - -printf '%s\n' '== Starting Base Registry Engine on loopback (background, metrics enabled)' -BREG_LOG=info nohup "$breg" --config "$run_dir/runtime.yaml" \ - >"$run_dir/logs/breg.log" 2>&1 & -echo $! >"$run_dir/breg.pid" -python3 "$support" wait-http --url "http://127.0.0.1:${breg_port}/ready" --timeout 30 -python3 "$support" wait-http --url "http://127.0.0.1:${metrics_port}/metrics" --timeout 30 + "$run_dir/project" >"$run_dir/dev-report.json" +python3 "$support" environment \ + --root "$run_dir" \ + --dev-report "$run_dir/dev-report.json" \ + --bregctl "$bregctl" printf '\n%s\n' 'Base Registry Engine load-test environment is ready.' printf ' Base Registry Engine: http://127.0.0.1:%s\n' "$breg_port" -printf ' Metrics: http://127.0.0.1:%s/metrics\n' "$metrics_port" -printf ' Registry Mint: http://127.0.0.1:%s\n' "$mint_port" -printf ' Database: 127.0.0.1:%s (container %s)\n' "$database_port" "$container" -printf ' Pool max size: %s\n' "$pool_max" -printf ' Environment: %s\n' "$run_dir/env.json" -printf ' Seed next: products/breg/loadtest/seed.py --count 100000\n' -printf ' Then run: products/breg/loadtest/run.sh --profile steady\n' -printf ' Tear down with: products/breg/loadtest/down.sh\n' - -if [[ "$keep" == true ]]; then - printf '%s\n' 'Note: --keep only records the environment for down.sh; data is not persisted across down/up cycles.' -fi +printf ' Identity provider: stock ThunderID 1.0.1 through bregctl dev\n' +printf ' Database: owned bregctl dev PostgreSQL on 127.0.0.1:%s\n' "$database_port" +printf ' BReg pool max size: 4 (the stock development lifecycle default)\n' +printf ' Environment: %s\n' "$run_dir/env.json" +printf ' Seed next: products/breg/loadtest/seed.py --count 100000\n' +printf ' Then run: products/breg/loadtest/run.sh --profile steady\n' +printf ' Tear down with: products/breg/loadtest/down.sh\n' trap - EXIT From ad55a2ec2954293771a98816bbe820bed6140263 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:24:45 +0700 Subject: [PATCH 072/120] docs(cli): review pinned spatial development image Signed-off-by: Jeremi Joslin --- docs/site/src/data/cli-reference.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 02cf4a188e..9346d43e44 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-13 reviewed_source_version: "0.30.0" -reviewed_catalog_sha256: 8ceb8124fa5820892edc57a41ad31bfa9b820645828f78f94f23433a284458f2 -reviewed_content_sha256: 8ab656d13d7740f84bb510a70d9e55beda1a1491d268cd369fae7804fb2698b2 +reviewed_catalog_sha256: 178d3a1515f4a339314434bd3c359af073171d3b0f85bd300b4fda929d81feee +reviewed_content_sha256: f40a029c43edbc9a8352a44b0a1fbfa4eb12acf16499da228b5fae97d2f3f324 From df62eb4783bcac67ead3ff522ad58025194ac18d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:27:33 +0700 Subject: [PATCH 073/120] fix(breg): preserve stock issuer demo journeys Signed-off-by: Jeremi Joslin --- .../tutorials/review-registry-changes.mdx | 25 +- .../send-registry-events-to-a-webhook.mdx | 355 +++--------------- products/breg/demo/README.md | 5 +- products/breg/demo/run.sh | 27 +- products/breg/demo/support/demo.py | 116 ++++-- products/breg/demo/support/test_demo.py | 12 +- products/breg/quickstart/README.md | 4 +- products/breg/quickstart/run.sh | 8 +- .../breg/quickstart/support/quickstart.py | 21 +- products/breg/scripts/test_quickstart.py | 170 ++------- 10 files changed, 223 insertions(+), 520 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/review-registry-changes.mdx b/docs/site/src/content/docs/tutorials/review-registry-changes.mdx index d72f4755be..b7cccfcdc0 100644 --- a/docs/site/src/content/docs/tutorials/review-registry-changes.mdx +++ b/docs/site/src/content/docs/tutorials/review-registry-changes.mdx @@ -37,7 +37,7 @@ You will run the supplied workflow against PostgreSQL, adapt its configuration, ## Before you start -[Create and query your first registry](../first-breg/) installs `breg`, `bregctl`, and `mint`. +[Create and query your first registry](../first-breg/) installs `breg` and `bregctl`. The example project, and the launcher this tutorial borrows a database from, live in the repository, so clone it at the version you installed: @@ -211,11 +211,12 @@ explains each grant and the `retention` mode the example selects. In a second terminal, at the root of the same clone, start the local quickstart. You are borrowing its disposable PostgreSQL cluster, which the runner reaches over TLS with the quickstart's certificate authority. -The `--installed` flag makes the launcher use the `breg`, `bregctl`, and `mint` you installed +The `--installed` flag makes the launcher use the `breg` and `bregctl` you installed rather than building them from the checkout. :::caution[Disposable local services] -Starting the quickstart replaces its previous `products/breg/quickstart/.run` directory. +The quickstart refuses an existing `products/breg/quickstart/.run` directory so +it cannot overwrite retained or unrelated state. Do not run two quickstarts in the same clone. Stopping the launcher removes its database container and the records inside it. Keep real data and production credentials out of this tutorial. @@ -225,12 +226,10 @@ Keep real data and production credentials out of this tutorial. products/breg/quickstart/run.sh --installed ``` -The launcher first prints `== Using installed breg, bregctl, and mint from PATH` with the three -paths it resolved. Wait for this line, then leave the terminal running: ```text -Base Registry Engine generic quickstart is ready. +Base Registry Engine quickstart is ready. ``` ## Run the approval workflow @@ -261,12 +260,16 @@ from urllib.parse import urlsplit import shlex import sys run = Path(sys.argv[1]) -connection = urlsplit((run / "secrets/runtime-database-url").read_text().strip()) -password = (run / "secrets/database-password").read_text().strip() -url = f"postgresql://postgres:{password}@localhost:{connection.port}/postgres" +connection = urlsplit((run / "project/.breg/dev/secrets/runtime-database-url").read_text().strip()) +database = dict( + line.split("=", 1) + for line in (run / "project/.breg/dev/database/postgres.env").read_text().splitlines() + if "=" in line +) +url = f"postgresql://{database['POSTGRES_USER']}:{database['POSTGRES_PASSWORD']}@localhost:{connection.port}/postgres" values = { "BREG_TEST_DATABASE_URL": url, - "BREG_TEST_TLS_CA_PEM_PATH": str(run / "tls/ca.pem"), + "BREG_TEST_TLS_CA_PEM_PATH": str(run / "project/.breg/dev/tls/ca.pem"), } target = Path(sys.argv[2]) target.touch(mode=0o600) @@ -422,7 +425,7 @@ deployment are separate steps. | Renaming the stage makes `check` fail | Match the stage `id` to the supervisor's `reviewStages` entry. | | The runner reports `test.step.failed` | Its path `journeys[0].steps[]` counts the steps of `tests/journeys.yaml` from zero. Check that step's `stage` and expected status. | | `change-requests` is not a recognized explain subject | Your `bregctl` predates the checkout. Confirm that `bregctl --version` matches the checkout's tag, then reinstall or clone again so they agree. | -| `command not found` for `bregctl` or `mint` | The installers in [Create and query your first registry](../first-breg/) place all three binaries in `~/.local/bin`. Add that directory to `PATH` in this terminal, or rerun the installer you skipped. | +| `command not found` for `bregctl` | The installer in [Create and query your first registry](../first-breg/) places `breg` and `bregctl` in `~/.local/bin`. Add that directory to `PATH` in this terminal, or rerun the installer you skipped. | ## Next diff --git a/docs/site/src/content/docs/tutorials/send-registry-events-to-a-webhook.mdx b/docs/site/src/content/docs/tutorials/send-registry-events-to-a-webhook.mdx index 6dbf573a1e..dcace4fdb8 100644 --- a/docs/site/src/content/docs/tutorials/send-registry-events-to-a-webhook.mdx +++ b/docs/site/src/content/docs/tutorials/send-registry-events-to-a-webhook.mdx @@ -1,11 +1,11 @@ --- title: Send registry events to a webhook -description: Run the business demo with a local receiver, read an event declaration, cause a signed CloudEvents delivery, and inspect an automatic retry, a dead letter, and its replay. +description: Run the business demo with its supervised receiver, cause a signed CloudEvents delivery, and inspect the receipt without exposing credentials. status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-09-08" +last_reviewed: "2026-09-13" doc_type: tutorial persona: - data publisher @@ -15,205 +15,66 @@ standards_referenced: [] import QuickstartMeta from '../../../components/QuickstartMeta.astro'; -If you finished [Create and query your first registry](../first-breg/), you are a data publisher with the -installed binaries, and you want to know what leaves a registry when a record changes. -In this tutorial you run the business demo, whose launcher starts a local receiver that verifies signatures, -read the event the project declares, cause one delivery yourself, and inspect an automatic retry, -a dead letter, and its replay in the receiver's report. -You write no receiver and edit no project. +This tutorial uses the maintained Base Registry Engine (BReg) business demo and the receiver supervised by +`bregctl dev`. You will read the event declaration, create a record, and inspect +the value-redacted delivery receipt. -{/* Evidence: products/breg/demo/run.sh; - products/breg/demo/support/demo.py, WEBHOOK_MODULE_SOURCE and WebhookReceiver; - crates/registry-bregctl/src/lib.rs, WebhookCommand; - crates/registry-breg/src/outbox.rs; crates/registry-breg/src/webhook.rs. */} +## Start the demo receiver -## Get the demo files - -Open a terminal in a directory of your choice. -The demo launcher and its receiver live in the repository, so clone it at the version you installed: - -```sh -installed="$(bregctl --version | awk '{print $2}')" -git clone --depth 1 --branch "v$installed" https://github.com/registrystack/registry-stack.git breg-tutorial -cd breg-tutorial -``` - -The project is pre-1.0, so the default branch may carry changes the installed binaries do not -implement; pinning the checkout keeps the launcher and the binaries on one contract. -Create a directory inside the checkout for the files this tutorial keeps; the checkout's `.gitignore` -covers it: +From the Registry Stack checkout, run: ```sh -mkdir -p tutorial-work +products/breg/demo/run.sh --webhook ``` -## Start the demo with a receiver +The launcher creates a disposable project under `products/breg/demo/.run`, +starts PostgreSQL, the stock ThunderID issuer, Base Registry Engine, and the +loopback receiver. Leave it running in this terminal. -:::caution[This is a separate disposable database] -The demo replaces `products/breg/demo/.run/` on each launch, and stopping it removes its database container. -Use synthetic records only, and keep anything you want to keep in `tutorial-work/`. -::: +## Read the declaration -In the first terminal, at the root of the `breg-tutorial` checkout and with the registry from the first -tutorial stopped, run: - -```sh -products/breg/demo/run.sh --installed --webhook -``` - -Wait for these two lines; other setup output is omitted: - -```text -Webhook delivery, retry, dead-letter inspection, and replay passed. -Base Registry Engine business demo is ready. -``` - -Leave this terminal running. -The launcher seeded eight establishments, seven of them operating, and each of those emitted the configured event. -Its receiver refused some attempts on purpose, so the report you read later already contains a retry, -a dead letter, and a replay. - -## Read the event declaration - -In the second terminal, save the demo's run directory and open the module that declares the event: +In another terminal: ```sh webhook_run="$PWD/products/breg/demo/.run" -cat "$webhook_run/project/modules/business-establishment-summary/module.yaml" -``` - -An event is a declaration on an entity that says which committed changes leave the registry and what they carry. -Find the `events` list under the `establishment` extension: - -```yaml -events: - - id: operating-created-v1 - trigger: created - projection: [establishment-code, operating-status] - when: - kind: fields - afterEquals: - operating-status: operating - webhook: - destinationId: business-event-receiver +grep -A18 '^ events:' \ + "$webhook_run/project/modules/business-establishment-summary/module.yaml" ``` -Three parts do the work. -`trigger` names the change that may emit the event, here a create. -`projection` is the complete list of values that may leave: two fields, not the site name or opening date. -`when` narrows the trigger to changes whose values match, here a new establishment whose `operating-status` is `operating`. -`webhook.destinationId` names a destination by id only; where the request goes is not the project's business. - -## Find the destination - -An event destination is the deployment's binding for that id: the receiver's address, network profile, -signing key reference, and delivery ceilings. -It lives in the runtime configuration, not in the project: - -```sh -grep -A11 '^eventDestinations:' "$webhook_run/runtime.yaml" -``` - -```yaml -eventDestinations: - business-event-receiver: - origin: http://127.0.0.1: - path: /events - networkProfile: loopbackDevelopmentHttp - dnsFamily: dualStackStrict - allowedPrivateCidrs: [] - hmacSha256KeyRef: secret:file/webhook-key - classificationCeiling: restricted - deliveryCeilings: - attemptTimeoutMilliseconds: 1000 - maximumAttempts: 3 -``` - -The key reference points at a file the launcher generated; the key itself never appears in configuration. -Do not edit these running deployment files: a change needs a checked package and a restart. - -Now see how the compiled project pairs the declaration with its retry profile: +The `operating-created-v1` event selects the establishment entity, the created +trigger, and the projected `establishment-code` and `operating-status` values. +The compiled destination binding, retry policy, and disclosure can be inspected +without opening runtime secrets: ```sh bregctl explain events "$webhook_run/project" ``` -After `Explained the compiled inventory.` and the project's findings, the report has one delivery, -`events.establishment.operating-created-v1.webhook`. -A delivery is one event bound to one destination, and the report shows what the project declares for it: -`deliveryMode` is `after_commit`, `maximumAttempts` is `5` with `retryDelaysMs` of `1000, 2000, 4000, 8000`, -`attemptTimeoutMs` is `5000`, `deadLetter` is `required`, and `operatorReplay` is `true`. -The destination's `deliveryCeilings` lower those numbers to three attempts and a one-second timeout for this demo. -A ceiling may tighten what the project declares, never widen it. - -{/* Evidence: crates/registry-breg/src/outbox.rs; crates/registry-breg/src/webhook.rs; - crates/registry-bregctl/src/lib.rs, ExplainSubject. */} - -## Preview the delivery - -Render the request the registry would send, without sending it: - -```sh -bregctl webhook sample "$webhook_run/project" --event operating-created-v1 -``` - -```text -Built the sample delivery. The canonical request follows. - event operating-created-v1 - -POST HTTP/1.1 -Accept: application/json -Content-Type: application/json -Idempotency-Key: sha256:0000000000000000000000000000000000000000000000000000000000000000 -X-Registry-Delivery-Attempt: 1 -X-Registry-Delivery-Time: 2026-01-01T00:00:00Z -X-Registry-Event-Generation: 1 -X-Registry-Signature: v1= -ce-dataschema: urn:breg:event-schema:business-establishments:establishment:operating-created-v1:sha256: -ce-id: 00000000-0000-4000-8000-000000000001 -ce-source: urn:registrystack:registry:business-establishments:instance: -ce-specversion: 1.0 -ce-time: 2026-01-01T00:00:00Z -ce-type: operating-created-v1 - -{"entity":"establishment","packageRevision":"sha256:0000000000000000000000000000000000000000000000000000000000000000","recordId":"00000000-0000-4000-8000-000000000002","revision":1,"trigger":"created","values":{"establishment-code":"x","operating-status":"operating"}} -``` - -A CloudEvents delivery is one HTTP POST in CloudEvents 1.0 binary mode: the `ce-` headers carry the event's -identity, source, type, and time, and the JSON body carries the entity, record identifier, revision, trigger, -and the projected `values` under their authored names. -Three registry headers sit beside them: `Idempotency-Key` so a receiver can drop a duplicate, -`X-Registry-Event-Generation` with `X-Registry-Delivery-Attempt` so it can tell a retry from a replay, -and `X-Registry-Signature`, an HMAC-SHA-256 signature the running worker computes with the destination's key. -The sample's identifiers and signature are placeholders; everything else is exact. - ## Cause a delivery -Obtain a fresh token from this demo's Mint client and keep it in an owner-only header file: +Acquire a fresh token through the retained dev session and copy its protected +header file: ```sh -webhook_url=$(cat "$webhook_run/breg-origin") umask 077 -mint token \ - --url "$(cat "$webhook_run/mint-origin")/token" \ - --client-id business-demo \ - --key "$webhook_run/keys/operator/signing-p256-private-jwk" \ - > "$webhook_run/secrets/operator-token" && -sed 's/^/Authorization: Bearer /' "$webhook_run/secrets/operator-token" \ - > tutorial-work/webhook-authorization.header +bregctl --format json dev token business-demo "$webhook_run/project" \ + > tutorial-work/webhook-token.json +python3 -c 'import json,shutil,sys; shutil.copyfile(json.load(open(sys.argv[1]))["headerFile"], sys.argv[2])' \ + tutorial-work/webhook-token.json tutorial-work/webhook-authorization.header +chmod 600 tutorial-work/webhook-authorization.header ``` -Success prints nothing; the token lasts five minutes, and the same command renews it. Create an operating establishment: ```sh +webhook_url=$(cat "$webhook_run/breg-origin") curl --silent --show-error --fail-with-body \ --header @tutorial-work/webhook-authorization.header \ --header 'Content-Type: application/json' \ @@ -223,158 +84,42 @@ curl --silent --show-error --fail-with-body \ "$webhook_url/v1/records/establishments?accessProfile=business-operator" ``` -```text -HTTP 201 -``` +The response is `201`. Delivery runs after the transaction commits, so allow a +few seconds before reading the receiver report. -Count the events the receiver has accepted: +## Inspect the receipt ```sh -python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))["events"]))' \ - "$webhook_run/webhook-receiver-state.json" -``` - -The count reaches `8`: the seven seeded deliveries plus yours. -The registry evaluated the event inside your create and handed the delivery to a worker that sends it -after the commit, so if you still see `7`, count again in a moment. -Repeat the create with its original idempotency key and the count stays `8`: a retried request is not a second change. -Create another establishment with `operatingStatus` set to `suspended` and a new key, and the count still -stays `8`: the `when` condition did not match. - -## Read the delivery history - -Open the receiver's report: - -```sh -python3 -m json.tool "$webhook_run/webhook-receiver-state.json" -``` - -`verificationFailures` is `0`: every request carried a valid signature and well-formed headers. -Each event is keyed by its `ce-id` and lists its `attempts`, each with a `generation`, an `attempt` number, -and whether the receiver `accepted` it. -Most events show one accepted attempt. -One shows a refused attempt `1` followed by an accepted attempt `2` in the same generation: a retry is the -worker sending the same delivery again after a refused or failed attempt, with the delays you saw in -`explain events`, the same event id, and the same idempotency key. -Then find the dead letter and its replay: - -```json -"attempts": [ - { - "accepted": false, - "attempt": 1, - "generation": 1 - }, - { - "accepted": false, - "attempt": 2, - "generation": 1 - }, - { - "accepted": false, - "attempt": 3, - "generation": 1 - }, - { - "accepted": true, - "attempt": 1, - "generation": 2 - } -], -"idempotencyKeys": { - "1": "sha256:", - "2": "sha256:" -} +bregctl --format json dev events "$webhook_run/project" \ + > tutorial-work/webhook-events.json +python3 -m json.tool tutorial-work/webhook-events.json ``` -Three refused attempts is the destination's ceiling, so the delivery became a dead letter: -the worker stopped, kept the payload, and left the delivery for an operator. -The launcher then replayed it. -A replay is an operator-requested new generation of the same event: the same `ce-id`, -`X-Registry-Event-Generation` raised to `2`, a fresh idempotency key, and an audit entry. -The report records no establishment values, only delivery metadata. - -Read the dead-letter list the launcher saved before that replay: - -```sh -python3 -m json.tool "$webhook_run/webhook-list.json" -``` - -```json -{ - "ok": true, - "command": "webhook list", - "deliveries": [ - { - "eventId": "", - "deliveryId": "events.establishment.operating-created-v1.webhook", - "generation": 1, - "state": "dead_lettered", - "attempt": 3, - "payloadAvailable": true, - "payloadExpiresAt": "", - "replayEligible": true - } - ] -} -``` - -`replayEligible` is true because the delivery is in its terminal dead-letter state and its payload is still retained. -The demo keeps payloads for one day; after `payloadExpiresAt`, a replay is no longer possible. -Now list the current queue. -The command connects to PostgreSQL directly, so give it the demo's certificate authority file: - -```sh -SSL_CERT_FILE="$webhook_run/tls/ca.pem" bregctl --format json webhook list \ - --runtime-config "$webhook_run/runtime.yaml" -``` - -```json -{ - "ok": true, - "command": "webhook list", - "deliveries": [] -} -``` - -The list is empty: the registry erases a payload as soon as its delivery succeeds, and this command shows -pending, dead-lettered, and expired work, not a history. -The receiver's report is where you look for what happened, and the public record API exposes no route to the queue at all. - -{/* Evidence: crates/registry-breg/src/webhook.rs; crates/registry-breg/src/outbox.rs; - products/breg/demo/support/demo.py, WebhookReceiver; - crates/registry-bregctl/src/lib.rs, WebhookCommand. */} +The report identifies the compiled event and destination, generation, attempt, +and receipt time. Payloads are omitted unless you explicitly add +`--include-payload`. A receipt means the receiver accepted that attempt. Operated +deployments inspect pending and dead-letter work through the separate webhook +operations described in [Operate BReg webhooks](../../operate/breg-webhooks/). ## Stop the demo -:::caution[Stopping deletes the demo database] -Pressing `Ctrl+C` in the first terminal stops Base Registry Engine, Mint, and the receiver, and removes the PostgreSQL -container with every establishment in it. -::: - -Stop the demo now. -The reports remain under `.run/` until the next launch, beside disposable keys and tokens. -`tutorial-work/webhook-authorization.header` holds a token: the checkout's `.gitignore` keeps it out of -commits, so keep it out of support messages too. - -## What you built +Press `Ctrl+C` in the first terminal. A successful run removes its owned dev +session. An interrupted or failed run retains `.run/` for diagnosis. Stop that +owned session before removing the directory and retrying. -You caused one signed CloudEvents delivery from a record you created, read the declaration that selected it -and the destination that received it, and traced a retry, a dead letter, and its replay through the -receiver's report and the registry's queue listing. -Declaring events in your own project, and binding receivers with real keys and ceilings, come next. +Keep `tutorial-work/webhook-authorization.header` out of support messages. The +checkout ignores it, but it remains a bearer credential until it expires. ## Troubleshooting | Symptom | Next move | | --- | --- | -| A request returns `401` | The token expired. Rerun the `mint token` command in [Cause a delivery](#cause-a-delivery) while the first terminal is still running. | -| The count stays at `7` | Delivery runs after the commit, on the worker's schedule. Count again after a few seconds, then check `.run/logs/` under the demo directory. | -| `webhook list` reports `webhook.operation.refused` | The command could not verify the database's TLS certificate. Prefix it with `SSL_CERT_FILE="$webhook_run/tls/ca.pem"` as shown. | -| The launcher stops before the ready lines | Read its last lines for the cause, then rerun it; a rerun replaces the previous disposable run. | +| A request returns `401` | Rerun `bregctl dev token business-demo "$webhook_run/project"` while the demo remains active and copy the new `headerFile`. | +| The new receipt is not visible yet | Wait a few seconds and rerun `bregctl dev events`. | +| The launcher stops before its ready line | Read the bounded diagnostics it prints. Preserve the failed `.run/` directory until you have diagnosed the owned session. | ## Next - [Author a registry project](../../configure/breg/) to declare events on your own entities. -- [Bind webhook receivers](../../operate/breg-webhooks/) for destinations, keys, retry limits, and replaying a dead letter yourself. -- [Base Registry Engine API reference](../../reference/breg-api/) for the delivery contract a receiver validates, verifies, and deduplicates. +- [Operate BReg webhooks](../../operate/breg-webhooks/) to configure operated destinations, retry limits, and dead-letter recovery. +- [Base Registry Engine API reference](../../reference/breg-api/) for the delivery contract a receiver validates and deduplicates. diff --git a/products/breg/demo/README.md b/products/breg/demo/README.md index 106c6c905c..50db08c92b 100644 --- a/products/breg/demo/README.md +++ b/products/breg/demo/README.md @@ -67,7 +67,8 @@ products/breg/demo/run.sh --webhook --smoke ``` All generated project, service state, credentials, and reports live under -`demo/.run/` or the explicit `--state-dir`. A new disposable run first stops -and removes any previous owned dev session before replacing that directory. +`demo/.run/` or the explicit `--state-dir`. A new run refuses an existing path. +Successful runs remove their owned state; failed or interrupted runs retain it +for diagnosis. Stop the owned dev session before removing that directory and retrying. Production deployments require an operated issuer, signer custody, signed packages, and an operated PostgreSQL service. diff --git a/products/breg/demo/run.sh b/products/breg/demo/run.sh index b0899f430a..d978a947d7 100755 --- a/products/breg/demo/run.sh +++ b/products/breg/demo/run.sh @@ -83,12 +83,22 @@ if [[ "$webhook" == true ]]; then prepare+=(--webhook) fi "${prepare[@]}" +if [[ "$webhook" == true ]]; then + "$bregctl" --format json explain model "$run_dir/project" >"$run_dir/webhook-model-report.json" + python3 "$support" bind-webhook-module \ + --root "$run_dir" \ + --report "$run_dir/webhook-model-report.json" +fi read -r database_port issuer_port breg_port < <(python3 "$support" ports) cleanup() { - "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true - if [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-demo-v1 ]]; then + local exit_code=$? + local remove=() + [[ "$exit_code" -eq 0 ]] && remove=(--remove) + "$bregctl" dev stop "${remove[@]}" --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true + if [[ "$exit_code" -eq 0 ]] && [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-demo-v1 ]]; then rm -rf -- "$run_dir" fi + return "$exit_code" } trap cleanup EXIT trap 'exit 129' HUP @@ -108,9 +118,16 @@ for client in json.load(open(sys.argv[1], encoding='utf-8'))['clients']: PY while read -r client profile; do "$bregctl" --format json dev token "$client" "$run_dir/project" >"$run_dir/$client-token-report.json" - token_name=$(python3 - "$fixture" "$profile" <<'PY' + token_name=$(python3 - "$fixture" "$profile" "$client" <<'PY' import sys -fixture, profile = sys.argv[1:] +fixture, profile, client = sys.argv[1:] +variants = { + 'business-demo-no-purpose': 'no-purpose', + 'household-demo-no-purpose': 'no-purpose', + 'asset-site-demo-planner-no-purpose': 'planner-no-purpose', + 'facility-demo-south-operator': 'south-operator', + 'inspection-demo-no-purpose': 'no-purpose', +} names = { ('business-establishments', 'business-operator'): 'operator', ('business-establishments', 'business-viewer'): 'viewer', @@ -127,7 +144,7 @@ names = { ('facility', 'facility-operator'): 'operator', ('inspection', 'inspection-inspector'): 'operator', } -print(names[(fixture, profile)]) +print(variants.get(client, names[(fixture, profile)])) PY ) python3 - "$run_dir/$client-token-report.json" "$run_dir/headers/$token_name.header" "$run_dir/secrets/$token_name-token" <<'PY' diff --git a/products/breg/demo/support/demo.py b/products/breg/demo/support/demo.py index 38f9d496a1..8d79e4b212 100755 --- a/products/breg/demo/support/demo.py +++ b/products/breg/demo/support/demo.py @@ -637,41 +637,61 @@ def prepare_dev(root: Path, fixture: Path, fixture_kind: str, webhook: bool = Fa root = _require_root(root) _local_project(root, fixture.resolve(), webhook, fixture_kind) project = root / "project" - journeys_path = project / "tests/journeys.yaml" - if journeys_path.is_file(): - lines = journeys_path.read_text().splitlines(keepends=True) - kept=[]; skip=False - for line in lines: - if line.startswith(" - id:"): - skip = "no-purpose" in line or "without-purpose" in line - if not skip: - kept.append(line) - journeys_path.write_text("".join(kept)) + registry = project / "registry.yaml" + source = registry.read_text(encoding="utf-8") if fixture_kind == "business-establishments": - registry = project / "registry.yaml" - source = registry.read_text() - source = source.replace(" - field: id\n claim: business_id", " - field: business-code\n claim: business_code", 1) - registry.write_text(source) + source = source.replace( + " - field: id\n claim: business_id", + " - field: business-code\n claim: business_code", + 1, + ) elif fixture_kind == "household": - registry = project / "registry.yaml" - source = registry.read_text() - source = source.replace(" - field: id\n claim: household_id", " - field: household-code\n claim: household_code", 1) - registry.write_text(source) - profiles: dict[str, list[tuple[str, list[str], dict[str, Any]]]] = { - "business-establishments": [("business-demo", ["registry:business:operate"], {"registry_principal":"synthetic-business-operator","registry_purpose":"business-administration"}), ("business-demo-viewer", ["registry:business:view"], {"registry_principal":"synthetic-business-viewer","registry_purpose":"business-view","business_code":"BUSINESS-DEMO-001"})], - "household": [("household-demo", ["registry:household:operate"], {"registry_principal":"synthetic-household-operator","registry_purpose":"household-administration"}), ("household-demo-viewer", ["registry:household:view"], {"registry_principal":"synthetic-household-viewer","registry_purpose":"household-view","household_code":"HOUSEHOLD-DEMO-001"})], - "asset-site": [("asset-site-demo-operator", [ASSET_OPERATOR_SCOPE], {"registry_principal":"synthetic-asset-operator","registry_purpose":"asset-management"}), ("asset-site-demo-planner", [ASSET_PLANNER_SCOPE], {"registry_principal":"synthetic-site-planner","registry_purpose":"site-planning"})], - "asset-change-request": [("asset-change-demo-operator", [ASSET_OPERATOR_SCOPE], {"registry_principal":"asset-operator","registry_purpose":"asset-management"}), ("asset-change-demo-planner", [ASSET_PLANNER_SCOPE], {"registry_principal":"synthetic-site-planner","registry_purpose":"site-planning"}), ("asset-change-demo-submitter", ["registry:corrections:submit"], {"registry_principal":"correction-submitter","registry_purpose":"asset-correction"}), ("asset-change-demo-reviewer", ["registry:corrections:review"], {"registry_principal":"correction-reviewer","registry_purpose":"asset-correction-review"}), ("asset-change-demo-supervisor", ["registry:corrections:supervise"], {"registry_principal":"correction-supervisor","registry_purpose":"asset-correction-review"}), ("asset-change-demo-applier", ["registry:corrections:apply"], {"registry_principal":"correction-applier","registry_purpose":"asset-correction-apply"})], - "facility": [("facility-demo-operator", [FACILITY_OPERATOR_SCOPE], {"administrative_boundaries":"north-district","registry_principal":"synthetic-facility-operator","registry_purpose":"facility-registry"})], - "inspection": [("inspection-demo-inspector", [INSPECTION_INSPECTOR_SCOPE], {"registry_principal":"synthetic-inspection-inspector","registry_purpose":"facility-inspection"})], + source = source.replace( + " - {field: id, claim: household_id, operator: equals}", + " - {field: household-code, claim: household_code, operator: equals}", + 1, + ) + registry.write_text(source, encoding="utf-8") + profiles: dict[str, list[dict[str, Any]]] = { + "business-establishments": [ + {"id":"business-demo", "profile":"business-operator", "scopes":["registry:business:operate"], "claims":{"registry_principal":"synthetic-business-operator", "registry_purpose":"business-administration"}}, + {"id":"business-demo-no-purpose", "profile":"business-operator", "scopes":["registry:business:operate"], "claims":{"registry_principal":"synthetic-business-operator"}, "testBindings":[{"journeyId":"business-establishment-lifecycle", "stepId":"operator-without-purpose-is-concealed"}]}, + {"id":"business-demo-viewer", "profile":"business-viewer", "scopes":["registry:business:view"], "claims":{"registry_principal":"synthetic-business-viewer", "registry_purpose":"business-view", "business_code":"BUSINESS-DEMO-001"}}, + ], + "household": [ + {"id":"household-demo", "profile":"household-operator", "scopes":["registry:household:operate"], "claims":{"registry_principal":"synthetic-household-operator", "registry_purpose":"household-administration"}}, + {"id":"household-demo-no-purpose", "profile":"household-operator", "scopes":["registry:household:operate"], "claims":{"registry_principal":"synthetic-household-operator"}, "testBindings":[{"journeyId":"household-person-lifecycle", "stepId":"operator-without-purpose-is-concealed"}]}, + {"id":"household-demo-viewer", "profile":"household-viewer", "scopes":["registry:household:view"], "claims":{"registry_principal":"synthetic-household-viewer", "registry_purpose":"household-view", "household_code":"HOUSEHOLD-DEMO-001"}}, + ], + "asset-site": [ + {"id":"asset-site-demo-operator", "profile":"asset-operator", "scopes":[ASSET_OPERATOR_SCOPE], "claims":{"registry_principal":"synthetic-asset-operator", "registry_purpose":"asset-management"}}, + {"id":"asset-site-demo-planner", "profile":"site-planner", "scopes":[ASSET_PLANNER_SCOPE], "claims":{"registry_principal":"synthetic-site-planner", "registry_purpose":"site-planning"}}, + {"id":"asset-site-demo-planner-no-purpose", "profile":"site-planner", "scopes":[ASSET_PLANNER_SCOPE], "claims":{"registry_principal":"synthetic-site-planner"}, "testBindings":[{"journeyId":"asset-and-site-caller-surfaces", "stepId":"planner-without-purpose-is-concealed"}]}, + ], + "asset-change-request": [ + {"id":"asset-change-demo-operator", "profile":"asset-operator", "scopes":[ASSET_OPERATOR_SCOPE], "claims":{"registry_principal":"asset-operator", "registry_purpose":"asset-management"}}, + {"id":"asset-change-demo-planner", "profile":"site-planner", "scopes":[ASSET_PLANNER_SCOPE], "claims":{"registry_principal":"synthetic-site-planner", "registry_purpose":"site-planning"}}, + {"id":"asset-change-demo-submitter", "profile":"correction-submitter", "scopes":["registry:corrections:submit"], "claims":{"registry_principal":"correction-submitter", "registry_purpose":"asset-correction"}}, + {"id":"asset-change-demo-reviewer", "profile":"correction-reviewer", "scopes":["registry:corrections:review"], "claims":{"registry_principal":"correction-reviewer", "registry_purpose":"asset-correction-review"}}, + {"id":"asset-change-demo-supervisor", "profile":"correction-supervisor", "scopes":["registry:corrections:supervise"], "claims":{"registry_principal":"correction-supervisor", "registry_purpose":"asset-correction-review"}}, + {"id":"asset-change-demo-applier", "profile":"correction-applier", "scopes":["registry:corrections:apply"], "claims":{"registry_principal":"correction-applier", "registry_purpose":"asset-correction-apply"}}, + ], + "facility": [ + {"id":"facility-demo-operator", "profile":"facility-operator", "scopes":[FACILITY_OPERATOR_SCOPE], "claims":{"administrative_boundaries":"north-district", "registry_principal":"synthetic-facility-operator", "registry_purpose":"facility-registry"}}, + {"id":"facility-demo-south-operator", "profile":"facility-operator", "scopes":[FACILITY_OPERATOR_SCOPE], "claims":{"administrative_boundaries":"south-district", "registry_principal":"synthetic-facility-operator", "registry_purpose":"facility-registry"}, "testBindings":[{"journeyId":"bounded-facility-and-batch-validation", "stepId":"south-district-claim-cannot-see-north-record"}]}, + ], + "inspection": [ + {"id":"inspection-demo-inspector", "profile":"inspection-inspector", "scopes":[INSPECTION_INSPECTOR_SCOPE], "claims":{"registry_principal":"synthetic-inspection-inspector", "registry_purpose":"facility-inspection"}}, + {"id":"inspection-demo-no-purpose", "profile":"inspection-inspector", "scopes":[INSPECTION_INSPECTOR_SCOPE], "claims":{"registry_principal":"synthetic-inspection-inspector"}, "testBindings":[{"journeyId":"inspection-and-schema-validation", "stepId":"inspector-without-purpose-is-concealed"}]}, + ], } - config = _fixture_config(fixture_kind) - clients=[] - for client_id, scopes, claims in profiles[fixture_kind]: - persona=next((item for item in config["personas"] if item["token_name"].replace("-token","") in client_id or item["access_profile"] in client_id), None) - profile = persona["access_profile"] if persona else ({"business-demo":"business-operator","business-demo-viewer":"business-viewer","household-demo":"household-operator","household-demo-viewer":"household-viewer","asset-site-demo-operator":"asset-operator","asset-site-demo-planner":"site-planner","asset-change-demo-operator":"asset-operator","facility-demo-operator":"facility-operator","inspection-demo-inspector":"inspection-inspector"}.get(client_id, client_id.removeprefix("asset-change-demo-"))) - clients.append({"id":client_id,"accessProfiles":[profile],"scopes":scopes,"claims":claims}) - (project / "dev-clients.yaml").write_text(json.dumps({"version":1,"clients":clients}, indent=2)+"\n") + clients = [] + for declaration in profiles[fixture_kind]: + client = {"id": declaration["id"], "accessProfiles": [declaration["profile"]], "scopes": declaration["scopes"], "claims": declaration["claims"]} + if "testBindings" in declaration: + client["testBindings"] = declaration["testBindings"] + clients.append(client) + (project / "dev-clients.yaml").write_text(json.dumps({"version": 1, "clients": clients}, indent=2) + "\n", encoding="utf-8") def bind_webhook_module( root: Path, @@ -1287,6 +1307,13 @@ def seed_business(root: Path) -> None: ) if [len(response.get("items", [])) for response in (establishments_response, business_response, assignment_response)] != [8, 3, 8]: raise DemoError("seeded list counts did not match the expected 8 establishments, 3 businesses, and 8 assignments") + _request( + root, + "GET", + f"/v1/records/establishments/{establishment_ids['ESTABLISHMENT-DEMO-001']}?accessProfile=business-operator", + "no-purpose-token", + expected=404, + ) print("Seeded 8 synthetic establishments, 3 businesses, and 8 current assignments.") @@ -1365,6 +1392,13 @@ def seed(root: Path) -> None: ) if [len(response.get("items", [])) for response in (people_response, household_response, membership_response)] != [8, 3, 8]: raise DemoError("seeded list counts did not match the expected 8 people, 3 households, and 8 memberships") + _request( + root, + "GET", + f"/v1/records/persons/{person_ids['PERSON-DEMO-001']}?accessProfile=household-operator", + "no-purpose-token", + expected=404, + ) print("Seeded 8 synthetic people, 3 households, and 8 current memberships.") @@ -1422,6 +1456,15 @@ def seed_asset_site(root: Path) -> None: response, _ = _request(root, "GET", route, "planner-token" if "site-planner" in route else "operator-token") if len(response.get("items", [])) != expected_count: raise DemoError(f"{route} did not return the expected seeded records") + concealed, _ = _request( + root, + "GET", + f"/v1/records/assets/{urllib.parse.quote(asset_id, safe='')}?accessProfile=site-planner", + "planner-no-purpose-token", + expected=404, + ) + if concealed.get("code") != "resource.not_found": + raise DemoError("planner without purpose did not receive the concealed resource response") print("Seeded 1 synthetic asset, site, placement, and create-only inspection.") @@ -1757,6 +1800,15 @@ def seed_inspection(root: Path) -> None: response, _ = _request(root, "GET", route, "operator-token") if len(response.get("items", [])) != expected_count: raise DemoError(f"{route} did not return the expected seeded records") + concealed, _ = _request( + root, + "GET", + f"/v1/records/inspections/{urllib.parse.quote(inspection_id, safe='')}?accessProfile=inspection-inspector", + "no-purpose-token", + expected=404, + ) + if concealed.get("code") != "resource.not_found": + raise DemoError("inspection token without purpose did not receive the concealed resource response") print("Seeded 1 inspection authority, inspection, structured observation, and 2 create-only permits.") diff --git a/products/breg/demo/support/test_demo.py b/products/breg/demo/support/test_demo.py index b2ecef6801..05683f60c4 100755 --- a/products/breg/demo/support/test_demo.py +++ b/products/breg/demo/support/test_demo.py @@ -24,21 +24,23 @@ def prepare(self, fixture: str): def test_business_clients_use_private_key_dev_contract(self): root, document = self.prepare("business-establishments") - self.assertEqual([c["id"] for c in document["clients"]], ["business-demo", "business-demo-viewer"]) - viewer = document["clients"][1] + self.assertEqual([c["id"] for c in document["clients"]], ["business-demo", "business-demo-no-purpose", "business-demo-viewer"]) + viewer = document["clients"][2] self.assertEqual(viewer["claims"]["business_code"], "BUSINESS-DEMO-001") registry = (root / "project/registry.yaml").read_text() self.assertIn("field: business-code\n claim: business_code", registry) - self.assertNotIn("operator-without-purpose-is-concealed", (root / "project/tests/journeys.yaml").read_text()) + self.assertIn("operator-without-purpose-is-concealed", (root / "project/tests/journeys.yaml").read_text()) + self.assertEqual(document["clients"][1]["testBindings"][0]["stepId"], "operator-without-purpose-is-concealed") def test_each_fixture_has_one_explicit_client_per_persona_profile(self): - expected = {"household": 2, "asset-site": 2, "asset-change-request": 6, "facility": 1, "inspection": 1} + expected = {"household": 3, "asset-site": 3, "asset-change-request": 6, "facility": 2, "inspection": 2} for fixture, count in expected.items(): with self.subTest(fixture=fixture): _, document = self.prepare(fixture) self.assertEqual(len(document["clients"]), count) profiles = [c["accessProfiles"][0] for c in document["clients"]] - self.assertEqual(len(profiles), len(set(profiles))) + variants = [c for c in document["clients"] if c.get("testBindings")] + self.assertEqual(len(variants), len(profiles) - len(set(profiles))) self.assertTrue(all(c["scopes"] and c["claims"] for c in document["clients"])) def test_prepare_refuses_existing_output(self): diff --git a/products/breg/quickstart/README.md b/products/breg/quickstart/README.md index 7e5911f12f..f9f8d00461 100644 --- a/products/breg/quickstart/README.md +++ b/products/breg/quickstart/README.md @@ -77,5 +77,7 @@ products/breg/quickstart/self-test.sh ``` All disposable files are under `quickstart/.run/`. The launcher removes its -owned dev session on exit. Production deployments require an operated issuer, +owned dev session after a successful run. It retains failed or interrupted state +for diagnosis; stop that owned session before removing `.run/` and retrying. +Production deployments require an operated issuer, signer custody, signed packages, and an operated PostgreSQL service. diff --git a/products/breg/quickstart/run.sh b/products/breg/quickstart/run.sh index df2a4f62a3..1554e672f9 100755 --- a/products/breg/quickstart/run.sh +++ b/products/breg/quickstart/run.sh @@ -31,10 +31,14 @@ else "$bregctl" --format json init "$run_dir/project" >"$run_dir/init-report.json" fi cleanup() { - "$bregctl" dev stop --remove --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true - if [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-quickstart-v1 ]]; then + local exit_code=$? + local remove=() + [[ "$exit_code" -eq 0 ]] && remove=(--remove) + "$bregctl" dev stop "${remove[@]}" --docker-bin "$(command -v docker)" "$run_dir/project" >/dev/null 2>&1 || true + if [[ "$exit_code" -eq 0 ]] && [[ -f "$run_dir/.launcher-owned" ]] && [[ "$(cat "$run_dir/.launcher-owned")" == registry-stack-breg-quickstart-v1 ]]; then rm -rf -- "$run_dir" fi + return "$exit_code" } trap cleanup EXIT trap 'exit 129' HUP diff --git a/products/breg/quickstart/support/quickstart.py b/products/breg/quickstart/support/quickstart.py index 156f018c75..3a00425573 100755 --- a/products/breg/quickstart/support/quickstart.py +++ b/products/breg/quickstart/support/quickstart.py @@ -32,22 +32,6 @@ def prepare_spatial(fixture: Path, project: Path): for old, new in (("service-sites:map.read", "service-sites:map:read"), ("service-sites:directory.read", "service-sites:directory:read"), ("service-sites:site.read", "service-sites:site:read")): text = text.replace(old, new) child.write_text(text, encoding="utf-8") - journey_path = project / "tests/journeys.yaml" - lines = journey_path.read_text(encoding="utf-8").splitlines(keepends=True) - kept = [] - skip = False - for line in lines: - if line.startswith(" - id:"): - skip = False - if line.strip() in ("accessProfile: map-reader", "accessProfile: directory-reader"): - while kept and not kept[-1].startswith(" - id:"): - kept.pop() - if kept: - kept.pop() - skip = True - if not skip: - kept.append(line) - journey_path.write_text("".join(kept), encoding="utf-8") (project/'dev-clients.yaml').write_text('''version: 1 clients: - id: operator @@ -116,5 +100,6 @@ def main(): elif a.cmd=='prepare-spatial-project': prepare_spatial(a.fixture,a.project) elif a.cmd=='request': generic(a.root,a.action,a.code,a.label,a.record_id) else: spatial_smoke(a.root,a.seed) -try: main() -except (QuickstartError,OSError,KeyError,json.JSONDecodeError) as e: print(f'quickstart: {e}',file=sys.stderr); raise SystemExit(1) +if __name__ == '__main__': + try: main() + except (QuickstartError,OSError,KeyError,json.JSONDecodeError) as e: print(f'quickstart: {e}',file=sys.stderr); raise SystemExit(1) diff --git a/products/breg/scripts/test_quickstart.py b/products/breg/scripts/test_quickstart.py index a4cdeb783e..9120aedfda 100755 --- a/products/breg/scripts/test_quickstart.py +++ b/products/breg/scripts/test_quickstart.py @@ -26,39 +26,15 @@ def load_helper(): return module -def prepare_root(root: Path) -> None: - (root / "project/tests").mkdir(parents=True) - (root / "secrets").mkdir() - (root / "keys").mkdir() - (root / "project/registry.yaml").write_text("apiVersion: registry.registrystack.org/v1alpha1\nkind: RegistryProject\n", encoding="utf-8") - (root / "project/tests/journeys.yaml").write_text( - "journeys:\n" - " - id: generic-smoke\n" - " steps:\n" - " - id: create-record\n" - " accessProfile: operator\n" - " - id: get-record\n" - " accessProfile: operator\n" - " - id: read-within-the-claim\n" - " accessProfile: record-reader\n" - " - id: list-records\n" - " accessProfile: operator\n", - encoding="utf-8", - ) - (root / "secrets/database-password").write_text("abcdef0123456789", encoding="ascii") - (root / "keys/mint-public.jwk.json").write_text('{"kid":"mint-key","kty":"EC"}', encoding="utf-8") - (root / "keys/operator-public.jwk.json").write_text('{"kid":"operator-key","kty":"EC"}', encoding="utf-8") - - def prepare_request_root(root: Path) -> None: root.chmod(0o700) - (root / "secrets").mkdir(mode=0o700) + (root / "headers").mkdir(mode=0o700) (root / "breg-origin").write_text("http://127.0.0.1:1\n", encoding="ascii") - token_path = root / "secrets/operator-token" - token_path.write_text("header.payload.signature", encoding="ascii") + token_path = root / "headers/operator.header" + token_path.write_text("Authorization: Bearer header.payload.signature\n", encoding="ascii") token_path.chmod(0o600) - map_token_path = root / "secrets/map-token" - map_token_path.write_text("header.payload.signature", encoding="ascii") + map_token_path = root / "headers/installation-map-reader.header" + map_token_path.write_text("Authorization: Bearer header.payload.signature\n", encoding="ascii") map_token_path.chmod(0o600) @@ -91,85 +67,29 @@ def test_offline_self_test_passes_without_network(self) -> None: def test_readme_keeps_local_and_production_paths_separate(self) -> None: readme = (QUICKSTART / "README.md").read_text(encoding="utf-8") - self.assertIn("bregctl init", readme) - # The launcher's claim is checked against unwrapped prose so a reflow of - # the paragraph does not read as a change of behaviour. unwrapped = " ".join(readme.split()) - self.assertIn("replaces only the initialized project's package identity", unwrapped) - self.assertIn("adds no model, profile, or catalogue metadata of its own", unwrapped) - self.assertIn("quickstart/.run/secrets/operator-token", readme) - self.assertIn("does not put the token on the command line", readme) - self.assertIn("unsigned", readme) - self.assertIn("local package", readme) - self.assertIn("Production pilots still require", readme) + self.assertIn("bregctl dev", readme) + self.assertIn("pinned stock ThunderID issuer", readme) + self.assertIn("private-key JWT clients", readme) + self.assertIn(".run/headers/operator.header", readme) + self.assertIn("does not put the bearer token on the command line", unwrapped) + self.assertIn("Production deployments require", readme) - def test_prepare_outputs_keep_generic_absent_and_spatial_explicit(self) -> None: + def test_spatial_project_uses_explicit_private_key_clients(self) -> None: helper = load_helper() - with tempfile.TemporaryDirectory() as generic_dir, tempfile.TemporaryDirectory() as spatial_dir: - generic_root = Path(generic_dir) - prepare_root(generic_root) - helper.prepare(generic_root, 15432, 18080, 18081) - generic_runtime = (generic_root / "runtime-test.yaml").read_text(encoding="utf-8") - generic_bootstrap = (generic_root / "database/bootstrap.sql").read_text(encoding="utf-8") - generic_initialize = (generic_root / "database/initialize.sql").read_text(encoding="utf-8") - generic_credentials = (generic_root / "schema-test-credentials.yaml").read_text(encoding="utf-8") - self.assertIn("allowedClients: [generic-quickstart, record-reader-quickstart]", generic_runtime) - self.assertIn("tokenRef: secret:file/schema-test-token}}", generic_credentials) - self.assertIn( - "stepId: read-within-the-claim, credential: {type: bearer, tokenRef: secret:file/reader-schema-test-token}}", - generic_credentials, - ) - reader_client = (generic_root / "mint/clients/record-reader-quickstart.yaml").read_text(encoding="utf-8") - self.assertIn('scopes: ["registry:generic:read"]', reader_client) - self.assertIn("registry_purpose: \"registry-reporting\"", reader_client) - self.assertIn("registry_record_status: \"active\"", reader_client) - self.assertNotIn("publicOrigin", generic_runtime) - self.assertNotIn("qgis-installation-central", generic_runtime) - self.assertNotIn("registry_spatial_ext", generic_initialize) - self.assertNotIn("registry_quickstart_runtime__spatial_bbox", generic_bootstrap) - self.assertNotIn("registry_quickstart_runtime__spatial_bbox", generic_initialize) - + with tempfile.TemporaryDirectory() as spatial_dir: spatial_root = Path(spatial_dir) - (spatial_root / "secrets").mkdir() - (spatial_root / "keys").mkdir() - helper.prepare_spatial_project(SPATIAL_FIXTURE, spatial_root / "project") - (spatial_root / "secrets/database-password").write_text("abcdef0123456789", encoding="ascii") - (spatial_root / "keys/mint-public.jwk.json").write_text('{"kid":"mint-key","kty":"EC"}', encoding="utf-8") - (spatial_root / "keys/operator-public.jwk.json").write_text('{"kid":"operator-key","kty":"EC"}', encoding="utf-8") - helper.prepare(spatial_root, 15432, 18080, 18081, True, "sha256:testfingerprint") + helper.prepare_spatial(SPATIAL_FIXTURE, spatial_root / "project") spatial_registry = (spatial_root / "project/registry.yaml").read_text(encoding="utf-8") - spatial_runtime = (spatial_root / "runtime-test.yaml").read_text(encoding="utf-8") - spatial_bootstrap = (spatial_root / "database/bootstrap.sql").read_text(encoding="utf-8") - spatial_initialize = (spatial_root / "database/initialize.sql").read_text(encoding="utf-8") - spatial_initialize_runtime = (spatial_root / "database/initialize-runtime.sql").read_text(encoding="utf-8") - qgis_client = (spatial_root / "mint/clients/qgis-installation-central.yaml").read_text(encoding="utf-8") - credentials = (spatial_root / "schema-test-credentials.yaml").read_text(encoding="utf-8") + clients = (spatial_root / "project/dev-clients.yaml").read_text(encoding="utf-8") self.assertIn("environment: local", spatial_registry) - self.assertIn("instanceId: generic_registry_local", spatial_registry) + self.assertIn("instanceId: generic-quickstart-local", spatial_registry) self.assertIn("manifestProjection:", spatial_registry) - self.assertIn("publicOrigin: http://127.0.0.1:18081", spatial_runtime) - self.assertIn("qgis-installation-central", spatial_runtime) - self.assertIn("registry_spatial_ext", spatial_initialize) - self.assertIn("registry_spatial_ext", spatial_initialize_runtime) - self.assertIn("CREATE ROLE registry_quickstart_runtime__spatial_bbox NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;", spatial_bootstrap) - self.assertIn("GRANT registry_quickstart_runtime__spatial_bbox TO registry_quickstart_migration WITH INHERIT FALSE, SET TRUE, ADMIN FALSE;", spatial_bootstrap) - self.assertIn("GRANT USAGE ON SCHEMA registry_spatial_ext TO registry_quickstart_migration, registry_quickstart_runtime, registry_quickstart_runtime__spatial_bbox;", spatial_initialize) - self.assertIn("GRANT USAGE ON SCHEMA registry_spatial_ext TO registry_quickstart_migration, registry_quickstart_runtime, registry_quickstart_runtime__spatial_bbox;", spatial_initialize_runtime) - self.assertNotIn("registry_quickstart_runtime__spatial_bbox TO registry_quickstart_runtime", spatial_bootstrap) - self.assertNotIn("registry_quickstart_runtime__spatial_bbox LOGIN", spatial_bootstrap) - self.assertNotIn("AUTHORIZATION registry_quickstart_runtime__spatial_bbox", spatial_initialize) - self.assertNotIn("CREATE SCHEMA registry_spatial_ext AUTHORIZATION registry_quickstart_runtime__spatial_bbox", spatial_initialize) - self.assertIn("clientAuthentication:", qgis_client) - self.assertIn('secretFingerprints: ["sha256:testfingerprint"]', qgis_client) - admin_client = (spatial_root / "mint/clients/generic-quickstart.yaml").read_text(encoding="utf-8") - self.assertIn("synthetic-service-site-admin", admin_client) - self.assertIn("service_zones: central", qgis_client) - self.assertIn("create-central-service-site", credentials) - self.assertIn("installation-client-sees-own-central-row", credentials) - self.assertIn("public-map-reader-bbox-finds-central-site", credentials) - self.assertIn("directory-reader-bbox-is-refused", credentials) - self.assertIn("credential: {type: anonymous}", credentials) + self.assertIn("id: operator", clients) + self.assertIn("id: installation-map-reader", clients) + self.assertIn("service_zones: central", clients) + self.assertNotIn("clientAuthentication:", clients) def test_create_record_reads_record_identifier_from_registry_record_envelope(self) -> None: helper = load_helper() @@ -197,7 +117,7 @@ def fake_urlopen(request: object, timeout: float = 10) -> _FakeHttpResponse: captured = io.StringIO() with mock.patch("urllib.request.urlopen", fake_urlopen): with contextlib.redirect_stdout(captured): - helper.request(root, "create", "QS-001", "Quickstart example record", None) + helper.generic(root, "create", "QS-001", "Quickstart example record", None) self.assertEqual(captured.getvalue().strip(), "11111111-1111-1111-1111-111111111111") def test_spatial_smoke_reads_rows_from_items_and_rejects_legacy_records_key(self) -> None: @@ -216,72 +136,44 @@ def test_spatial_smoke_reads_rows_from_items_and_rejects_legacy_records_key(self } geojson = {"type": "FeatureCollection", "features": [{}]} - def fake_request_with_legacy_records_key( - root_arg: Path, - method: str, - path: str, - body: dict | None, - idempotency_key: str | None = None, - expected: int = 200, - token_name: str = "operator-token", - accept: str = "application/json", - ) -> dict: + def fake_request_with_legacy_records_key(root_arg, method, path, body=None, expected=200, client="operator", idem=None, accept="application/json"): if method == "POST": return created if accept == "application/geo+json": return geojson return {"records": [{}]} - with mock.patch.object(helper, "_request", fake_request_with_legacy_records_key): + with mock.patch.object(helper, "request", fake_request_with_legacy_records_key): with self.assertRaises(helper.QuickstartError): helper.spatial_smoke(root, seed) - def fake_request_with_items_key( - root_arg: Path, - method: str, - path: str, - body: dict | None, - idempotency_key: str | None = None, - expected: int = 200, - token_name: str = "operator-token", - accept: str = "application/json", - ) -> dict: + def fake_request_with_items_key(root_arg, method, path, body=None, expected=200, client="operator", idem=None, accept="application/json"): if method == "POST": return created if accept == "application/geo+json": return geojson return {"items": [{}], "pageInfo": {"nextCursor": None}, "meta": {}} - with mock.patch.object(helper, "_request", fake_request_with_items_key): + with mock.patch.object(helper, "request", fake_request_with_items_key): helper.spatial_smoke(root, seed) def test_spatial_launcher_preserves_generic_default_and_switches_only_on_flag(self) -> None: run_source = (QUICKSTART / "run.sh").read_text(encoding="utf-8") self.assertIn("spatial=false", run_source) - self.assertIn('postgres_image="$ordinary_postgres_image"', run_source) self.assertIn("--spatial", run_source) - self.assertIn("postgres_platform=(--platform linux/amd64)", run_source) self.assertIn("prepare-spatial-project", run_source) - self.assertIn("mint-client-secret-token", run_source) + self.assertIn("dev token installation-map-reader", run_source) self.assertIn("spatial-smoke", run_source) - self.assertIn("service-site.installation-map-reader", run_source) - self.assertIn("Base Registry Engine generic quickstart smoke passed", run_source) - self.assertIn("Base Registry Engine spatial quickstart smoke passed", run_source) + self.assertIn("Base Registry Engine quickstart is ready", run_source) def test_spatial_helper_keeps_secret_file_private_and_runtime_clients_explicit(self) -> None: helper_source = (QUICKSTART / "support/quickstart.py").read_text(encoding="utf-8") - self.assertIn('QGIS_CLIENT_ID = "qgis-installation-central"', helper_source) - self.assertIn("clientAuthentication:", helper_source) - self.assertIn("secretFingerprints:", helper_source) - self.assertIn('_require_owner_only_regular(secret_path, "QGIS client secret")', helper_source) - self.assertIn("registry_spatial_ext", helper_source) - self.assertIn('SPATIAL_BBOX_ROLE = "registry_quickstart_runtime__spatial_bbox"', helper_source) - self.assertIn("WITH INHERIT FALSE, SET TRUE, ADMIN FALSE", helper_source) - self.assertIn("publicOrigin: http://127.0.0.1", helper_source) - self.assertIn("service-sites:map.read", helper_source) + self.assertIn("id: installation-map-reader", helper_source) + self.assertIn("accessProfiles: [installation-map-reader]", helper_source) + self.assertIn("service-sites:map:read", helper_source) self.assertIn("synthetic-qgis-installation", helper_source) self.assertIn("service_zones: central", helper_source) - self.assertNotIn("print(secret", helper_source) + self.assertNotIn("clientAuthentication:", helper_source) if __name__ == "__main__": From 41be116d37649bed3e924a069779c1b6679b62d5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 02:32:25 +0700 Subject: [PATCH 074/120] feat(breg): preserve bounded dev fixture variants Signed-off-by: Jeremi Joslin --- crates/registry-bregctl/src/dev/config.rs | 55 +++++- crates/registry-bregctl/src/dev/mod.rs | 160 ++++++++++++++-- .../src/dev/prepare_source.rs | 1 + crates/registry-bregctl/src/dev/tests.rs | 175 ++++++++++++++++++ products/breg/DEV.md | 24 ++- products/breg/quickstart/run.sh | 2 +- 6 files changed, 392 insertions(+), 25 deletions(-) diff --git a/crates/registry-bregctl/src/dev/config.rs b/crates/registry-bregctl/src/dev/config.rs index 63f9799e9f..c120fbcd4f 100644 --- a/crates/registry-bregctl/src/dev/config.rs +++ b/crates/registry-bregctl/src/dev/config.rs @@ -38,6 +38,11 @@ pub(super) struct Client { pub allow_breg_access: bool, pub scopes: Vec, pub claims: BTreeMap, + /// Exact schema-test steps that use this claim variant. Runtime requests + /// still select an authored access profile; this field only disambiguates + /// credentials for maintained local journeys. + #[serde(default)] + pub test_bindings: Vec, pub client_id_file: Option, pub assertion_key_file: Option, } @@ -46,6 +51,13 @@ fn is_false(value: &bool) -> bool { !value } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct TestBinding { + pub journey_id: String, + pub step_id: String, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(super) struct Seed { @@ -76,9 +88,11 @@ pub(super) fn clients(bytes: &[u8]) -> Result { bail!("local clients v1 requires 1..32 explicit clients and at most 100 seed records"); } let mut ids = BTreeSet::new(); - let mut profiles = BTreeSet::new(); + let mut profile_defaults = BTreeSet::new(); + let mut test_bindings = BTreeSet::new(); let mut outputs = BTreeSet::new(); for client in &clients.clients { + let mut client_profiles = BTreeSet::new(); if client.id == "issuer" || !identifier(&client.id) || !ids.insert(&client.id) @@ -87,8 +101,24 @@ pub(super) fn clients(bytes: &[u8]) -> Result { bail!("local clients need unique bounded IDs and explicit scopes"); } for profile in &client.access_profiles { - if !identifier(profile) || !profiles.insert(profile) { - bail!("each local access profile must bind to exactly one teaching client"); + if !identifier(profile) || !client_profiles.insert(profile) { + bail!( + "local access profile bindings must be unique bounded identifiers per client" + ); + } + if client.test_bindings.is_empty() && !profile_defaults.insert(profile) { + bail!("a shared local access profile needs at most one default client; use exact testBindings for claim variants"); + } + } + if client.test_bindings.len() > 100 { + bail!("one local client may bind at most 100 schema-test steps"); + } + for binding in &client.test_bindings { + if !identifier(&binding.journey_id) + || !identifier(&binding.step_id) + || !test_bindings.insert(binding.clone()) + { + bail!("testBindings need unique exact bounded journeyId and stepId pairs"); } } if client.scopes.len() > 32 @@ -241,11 +271,24 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( } let mut ca_params = rcgen::CertificateParams::new(Vec::::new())?; ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "BREG local development CA"); + ca_params.key_usages = vec![ + rcgen::KeyUsagePurpose::KeyCertSign, + rcgen::KeyUsagePurpose::CrlSign, + ]; let ca_key = rcgen::KeyPair::generate()?; let ca = ca_params.self_signed(&ca_key)?; let server_key = rcgen::KeyPair::generate()?; - let server = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])? - .signed_by(&server_key, &ca, &ca_key)?; + let mut server_params = + rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?; + server_params + .distinguished_name + .push(rcgen::DnType::CommonName, "BREG local PostgreSQL"); + server_params.use_authority_key_identifier_extension = true; + server_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let server = server_params.signed_by(&server_key, &ca, &ca_key)?; private::create( &root.join("tls/ca.pem"), pem("CERTIFICATE", ca.der()).as_bytes(), @@ -393,7 +436,7 @@ pub(super) fn runtime( }), &json!({ "apiVersion":"registry.registrystack.org/breg-runtime/v1alpha1","kind":"BRegRuntimeConfig", - "listener":{"bind":format!("127.0.0.1:{}",state.breg_port)}, + "listener":{"bind":format!("127.0.0.1:{}",state.breg_port),"publicOrigin":state.breg_origin()}, "identity":{"environment":"local","instanceId":state.instance_id,"databaseId":DATABASE_ID,"databaseInitializationEnvironment":"local"}, "secretProviders":{"file":{"root":final_root.join("secrets")}}, "database":{"runtimeUrlRef":format!("secret:file/{prefix}runtime-database-url"),"migrationUrlRef":format!("secret:file/{prefix}migration-database-url"),"pool":{"maxSize":4},"roles":{"migration":MIGRATION_ROLE,"runtime":RUNTIME_ROLE}}, diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index b899eba40a..e70b7388b4 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -45,6 +45,8 @@ const MIGRATION_ROLE: &str = "breg_dev_migration"; const RUNTIME_ROLE: &str = "breg_dev_runtime"; const IMAGE: &str = "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675"; +const SPATIAL_IMAGE: &str = + "postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6"; const LABEL: &str = "org.registrystack.bregctl.dev-owner"; /// Refusal for a project that never started. Reporting a stopped session /// would claim owned services were stopped when none were ever created. @@ -74,6 +76,8 @@ enum DevAction { /// containers plus its Base Registry Engine (BReg) child. The database runs /// the pinned image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, + /// or, when the compiled schema requires PostGIS, + /// postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6, /// which the supervisor pulls on the first start. Each supervised /// prerequisite command may run for 120 seconds, and the database and each /// started service have 45 seconds to answer as ready. A start that passes @@ -201,6 +205,9 @@ struct State { #[serde(default)] issuer_image: Option, database_port: u16, + /// Fixed at first start from the compiled schema; retained with the database. + #[serde(default)] + requires_postgis: bool, /// Kernel-selected loopback receiver port, retained with destination bindings. #[serde(default)] webhook_port: Option, @@ -263,6 +270,13 @@ impl State { fn root(&self) -> PathBuf { self.project.join(".breg/dev") } + fn database_image(&self) -> &'static str { + if self.requires_postgis { + SPATIAL_IMAGE + } else { + IMAGE + } + } fn container_name(&self) -> String { format!("breg-dev-{}", self.owner) } @@ -563,6 +577,7 @@ fn prepare_receiver(state: &mut State, clients: &Clients) -> Result<()> { fn bind_journey_profiles(journeys: &[u8], clients: &Clients) -> Result<()> { let journeys: Value = serde_norway::from_slice(journeys) .context("tests/journeys.yaml must parse before local development starts")?; + let mut used = BTreeSet::new(); for journey in journeys["journeys"] .as_array() .context("journeys must contain an array")? @@ -571,18 +586,36 @@ fn bind_journey_profiles(journeys: &[u8], clients: &Clients) -> Result<()> { .as_array() .context("journey steps must be an array")? { + let journey_id = journey["id"].as_str().context("journey requires an id")?; + let step_id = step["id"].as_str().context("journey step requires an id")?; let profile = step["accessProfile"] .as_str() .context("journey step requires an access profile")?; - if !clients - .clients - .iter() - .any(|client| client.access_profiles.iter().any(|p| p == profile)) + if let Some(client) = exact_journey_client(clients, journey_id, step_id, profile)? { + used.insert((journey_id.to_owned(), step_id.to_owned())); + let _ = client; + continue; + } + if step["claims"] + .as_object() + .is_some_and(|claims| claims.is_empty()) { + continue; + } + let client = journey_client(clients, journey_id, step_id, profile)?; + if !client.test_bindings.is_empty() { + used.insert((journey_id.to_owned(), step_id.to_owned())); + } + } + } + for client in &clients.clients { + for binding in &client.test_bindings { + if !used.contains(&(binding.journey_id.clone(), binding.step_id.clone())) { bail!( - "journey step {} of {} uses access profile {profile}, which no client in the clients file binds; add a client with that profile and the claims the step expects before first start", - step["id"].as_str().unwrap_or("?"), - journey["id"].as_str().unwrap_or("?") + "client {} testBindings names unknown or profile-mismatched journey step {}/{}", + client.id, + binding.journey_id, + binding.step_id ); } } @@ -590,6 +623,69 @@ fn bind_journey_profiles(journeys: &[u8], clients: &Clients) -> Result<()> { Ok(()) } +fn journey_client<'a>( + clients: &'a Clients, + journey_id: &str, + step_id: &str, + profile: &str, +) -> Result<&'a config::Client> { + let candidates = clients + .clients + .iter() + .filter(|client| client.access_profiles.iter().any(|value| value == profile)) + .collect::>(); + let exact = candidates + .iter() + .copied() + .filter(|client| { + client + .test_bindings + .iter() + .any(|binding| binding.journey_id == journey_id && binding.step_id == step_id) + }) + .collect::>(); + if let [client] = exact.as_slice() { + return Ok(*client); + } + let defaults = candidates + .iter() + .copied() + .filter(|client| client.test_bindings.is_empty()) + .collect::>(); + if exact.is_empty() { + if let [client] = defaults.as_slice() { + return Ok(*client); + } + } + bail!( + "journey step {step_id} of {journey_id} needs one unambiguous local client for access profile {profile}; add one default client or one exact testBindings entry" + ) +} + +fn exact_journey_client<'a>( + clients: &'a Clients, + journey_id: &str, + step_id: &str, + profile: &str, +) -> Result> { + let exact = clients + .clients + .iter() + .filter(|client| client.access_profiles.iter().any(|value| value == profile)) + .filter(|client| { + client + .test_bindings + .iter() + .any(|binding| binding.journey_id == journey_id && binding.step_id == step_id) + }) + .collect::>(); + match exact.as_slice() { + [] => Ok(None), + [client] => Ok(Some(*client)), + _ => bail!("journey step {step_id} of {journey_id} has ambiguous exact testBindings"), + } +} + struct CapturedSource { files: BTreeMap>, digest: String, @@ -749,6 +845,7 @@ fn start(args: StartArgs) -> Result { .database_port .or(previous.map(|s| s.database_port)) .unwrap_or(55432), + requires_postgis: compiled.ddl().requires_postgis, webhook_port: None, clients_file, source_digest: digest, @@ -1821,7 +1918,7 @@ fn inspect(docker: &Path, state: &State) -> Result> { .context("Docker returned no exact container")?; if container["Name"] != format!("/{}", state.container_name()) || container["Config"]["Labels"][LABEL] != state.owner - || container["Config"]["Image"] != IMAGE + || container["Config"]["Image"] != state.database_image() || state .container_id .as_ref() @@ -1853,7 +1950,7 @@ fn database(docker: &Path, state: &mut State) -> Result<()> { root.join("database/postgres.env") .to_str() .context("dev path must be UTF-8")?, - IMAGE, + state.database_image(), ], None, )?; @@ -2007,6 +2104,9 @@ fn database(docker: &Path, state: &mut State) -> Result<()> { ] { statements.push_str(&format!("CREATE SCHEMA IF NOT EXISTS {schema} AUTHORIZATION {MIGRATION_ROLE}; REVOKE ALL ON SCHEMA {schema} FROM PUBLIC;")); } + if state.requires_postgis { + statements.push_str(&spatial_prerequisites_sql()); + } sql(docker, state, database, statements.as_bytes(), None)?; } state.database_ready = true; @@ -2014,6 +2114,24 @@ fn database(docker: &Path, state: &mut State) -> Result<()> { } Ok(()) } +/// Same role boundary as the runtime spatial prerequisite contract: the +/// migration role may SET the no-login bbox owner; runtime is never a member. +fn spatial_prerequisites_sql() -> String { + let bbox_role = format!("{RUNTIME_ROLE}__spatial_bbox"); + format!( + "CREATE SCHEMA IF NOT EXISTS registry_spatial_ext; \ + REVOKE ALL ON SCHEMA registry_spatial_ext FROM PUBLIC; \ + CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA registry_spatial_ext; \ + GRANT USAGE ON SCHEMA registry_spatial_ext TO {MIGRATION_ROLE}, {RUNTIME_ROLE}; \ + DO $breg_spatial$ BEGIN \ + IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '{bbox_role}') THEN \ + CREATE ROLE {bbox_role} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS; \ + END IF; END; $breg_spatial$; \ + GRANT {bbox_role} TO {MIGRATION_ROLE} WITH INHERIT FALSE, SET TRUE, ADMIN FALSE; \ + GRANT USAGE ON SCHEMA registry_spatial_ext TO {bbox_role};" + ) +} + fn sql( docker: &Path, state: &State, @@ -2193,6 +2311,9 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { ] { initialization.push_str(&format!("CREATE SCHEMA {schema} AUTHORIZATION {MIGRATION_ROLE}; REVOKE ALL ON SCHEMA {schema} FROM PUBLIC;")); } + if state.requires_postgis { + initialization.push_str(&spatial_prerequisites_sql()); + } sql( docker, state, @@ -2216,12 +2337,21 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { let profile = step["accessProfile"] .as_str() .context("journey step requires an access profile")?; - let client = clients - .clients - .iter() - .find(|client| client.access_profiles.iter().any(|p| p == profile)) - .context("every schema-test profile needs an explicit local client binding")?; - bindings.push(json!({"journeyId":journey["id"],"stepId":step["id"],"credential":{"type":"bearer","tokenRef":format!("secret:file/{}-token",client.id)}})); + let journey_id = journey["id"].as_str().context("journey requires an id")?; + let step_id = step["id"].as_str().context("journey step requires an id")?; + let explicit = exact_journey_client(clients, journey_id, step_id, profile)?; + let credential = if let Some(client) = explicit { + json!({"type":"bearer","tokenRef":format!("secret:file/{}-token",client.id)}) + } else if step["claims"] + .as_object() + .is_some_and(|claims| claims.is_empty()) + { + json!({"type":"anonymous"}) + } else { + let client = journey_client(clients, journey_id, step_id, profile)?; + json!({"type":"bearer","tokenRef":format!("secret:file/{}-token",client.id)}) + }; + bindings.push(json!({"journeyId":journey_id,"stepId":step_id,"credential":credential})); } } let credentials = json!({"apiVersion":"registry.registrystack.org/breg-schema-test-credentials/v1","kind":"SchemaTestCredentials","bindings":bindings}); diff --git a/crates/registry-bregctl/src/dev/prepare_source.rs b/crates/registry-bregctl/src/dev/prepare_source.rs index 1dee55e0eb..c12b511457 100644 --- a/crates/registry-bregctl/src/dev/prepare_source.rs +++ b/crates/registry-bregctl/src/dev/prepare_source.rs @@ -344,6 +344,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { allow_breg_access: false, scopes: vec![scope], claims, + test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, }; diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 99afa0f3d3..88bba14ef9 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -35,6 +35,7 @@ seed: [] issuer_port: 8095, issuer_image: None, database_port: 55448, + requires_postgis: false, webhook_port: None, clients_file: project.join("clients.yaml"), source_digest: "a".repeat(64), @@ -315,6 +316,148 @@ seed: ); } +#[test] +fn clients_allow_only_explicit_unambiguous_shared_profile_variants() { + let bytes = r#"version: 1 +clients: + - id: operator + accessProfiles: [operator] + scopes: [registry:records:write] + claims: {registry_principal: operator, registry_purpose: administration} + - id: operator-without-purpose + accessProfiles: [operator] + scopes: [registry:records:write] + claims: {registry_principal: operator} + testBindings: + - {journeyId: record-lifecycle, stepId: without-purpose-is-concealed} +"#; + let parsed = config::clients(bytes.as_bytes()).expect("one default plus an exact test variant"); + assert_eq!( + parsed.clients[1].test_bindings[0].step_id, + "without-purpose-is-concealed" + ); + + let duplicate = bytes.replace( + " - {journeyId: record-lifecycle, stepId: without-purpose-is-concealed}\n", + "", + ); + assert!(config::clients(duplicate.as_bytes()) + .unwrap_err() + .to_string() + .contains("at most one default")); + + let duplicate_binding = format!("{bytes} - id: another-variant\n accessProfiles: [operator]\n scopes: [registry:records:write]\n claims: {{registry_principal: operator}}\n testBindings:\n - {{journeyId: record-lifecycle, stepId: without-purpose-is-concealed}}\n"); + assert!(config::clients(duplicate_binding.as_bytes()) + .unwrap_err() + .to_string() + .contains("unique exact")); +} + +#[test] +fn journey_bindings_select_exact_claim_variant_and_reject_stale_entries() { + let client_bytes = br#"version: 1 +clients: + - id: operator + accessProfiles: [operator] + scopes: [registry:records:write] + claims: {registry_principal: operator, registry_purpose: administration} + - id: operator-without-purpose + accessProfiles: [operator] + scopes: [registry:records:write] + claims: {registry_principal: operator} + testBindings: + - {journeyId: record-lifecycle, stepId: without-purpose-is-concealed} +"#; + let clients = config::clients(client_bytes).unwrap(); + assert_eq!( + journey_client( + &clients, + "record-lifecycle", + "without-purpose-is-concealed", + "operator" + ) + .unwrap() + .id, + "operator-without-purpose" + ); + assert_eq!( + journey_client(&clients, "record-lifecycle", "create-record", "operator") + .unwrap() + .id, + "operator" + ); + let journeys = br#"journeys: + - id: record-lifecycle + steps: + - id: create-record + accessProfile: operator + claims: {principal: operator, purpose: administration} +"#; + assert!(bind_journey_profiles(journeys, &clients) + .unwrap_err() + .to_string() + .contains("unknown or profile-mismatched")); +} + +#[test] +fn anonymous_journey_steps_need_no_issuer_client() { + let clients = config::clients( + br#"version: 1 +clients: + - id: operator + accessProfiles: [operator] + scopes: [registry:records:write] + claims: {registry_principal: operator, registry_purpose: administration} +"#, + ) + .unwrap(); + let journeys = br#"journeys: + - id: public-read + steps: + - id: list-public + accessProfile: public-reader + claims: {} +"#; + bind_journey_profiles(journeys, &clients).unwrap(); +} + +#[test] +fn explicit_binding_precedes_empty_claims_and_rejects_profile_mismatch() { + let clients = config::clients( + br#"version: 1 +clients: + - id: authenticated-public-reader + accessProfiles: [public-reader] + scopes: [registry:records:read] + claims: {registry_principal: reader} + testBindings: + - {journeyId: public-read, stepId: list-public} +"#, + ) + .unwrap(); + let exact = exact_journey_client(&clients, "public-read", "list-public", "public-reader") + .unwrap() + .expect("the explicit binding wins even when authored claims are empty"); + assert_eq!(exact.id, "authenticated-public-reader"); + let journey = r#"journeys: + - id: public-read + steps: + - id: list-public + accessProfile: public-reader + claims: {} +"#; + bind_journey_profiles(journey.as_bytes(), &clients).unwrap(); + + let mismatched = journey.replace( + "accessProfile: public-reader", + "accessProfile: other-reader", + ); + assert!(bind_journey_profiles(mismatched.as_bytes(), &clients) + .unwrap_err() + .to_string() + .contains("unknown or profile-mismatched")); +} + #[test] fn credential_publication_recovers_one_owned_half_and_refuses_conflicting_bytes() { let (_temp, state, mut clients, files) = fixture(); @@ -601,6 +744,24 @@ fn database_roles_have_independent_passwords_and_hmac_files_are_secret_safe() { } } +#[test] +fn generated_postgres_leaf_verifies_against_its_distinct_ca() { + let (_temp, state, clients, files) = fixture(); + initialize(&state.root(), &state, &clients, &files).unwrap(); + let output = match Command::new("openssl") + .arg("verify") + .arg("-CAfile") + .arg(state.root().join("tls/ca.pem")) + .arg(state.root().join("tls/server.pem")) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => panic!("run openssl verify: {error}"), + }; + assert!(output.status.success(), "generated TLS chain must verify"); +} + /// The pinned image and the two deadlines are facts an operator checks before /// a first start. Hold the owning document, the command's own help text and /// the supervisor's constants equal so they cannot drift apart. @@ -626,6 +787,7 @@ fn the_documented_image_and_deadlines_are_the_supervisors_own() { .to_string(); for fact in [ IMAGE.to_owned(), + SPATIAL_IMAGE.to_owned(), format!("{} seconds", CHILD_DEADLINE.as_secs()), format!("{} seconds", READY_DEADLINE.as_secs()), ] { @@ -1026,6 +1188,7 @@ fn retained_session(project: &Path, container_id: Option) -> State { issuer_port: 8095, issuer_image: None, database_port: 55448, + requires_postgis: false, webhook_port: None, clients_file: project.join("dev-clients.yaml"), source_digest: captured.digest, @@ -1720,3 +1883,15 @@ fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { arbitrary.extend(["--purpose", "invented"]); assert!(::try_parse_from(arbitrary).is_err()); } + +#[test] +fn retained_database_selection_preserves_spatial_and_legacy_sessions() { + let (_temporary, mut state, _clients, _files) = fixture(); + let mut legacy = serde_json::to_value(&state).unwrap(); + legacy.as_object_mut().unwrap().remove("requiresPostgis"); + let restored: State = serde_json::from_value(legacy).unwrap(); + assert_eq!(restored.database_image(), IMAGE); + state.requires_postgis = true; + let restored: State = serde_json::from_slice(&serde_json::to_vec(&state).unwrap()).unwrap(); + assert_eq!(restored.database_image(), SPATIAL_IMAGE); +} diff --git a/products/breg/DEV.md b/products/breg/DEV.md index dce4d2949e..28eefdf39c 100644 --- a/products/breg/DEV.md +++ b/products/breg/DEV.md @@ -33,7 +33,11 @@ original ports and clients-file location. Conflicting ports are refused. The database runs the pinned image `postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675`, -so an operator can check exactly what the supervisor pulls. Each supervised +or `postgis/postgis@sha256:01a6a70e41e6c4467c8f55f6063555ed72db2d6662cd0d571040d42eadaeb6f6` +when the compiled schema requires PostGIS. The selection is retained with the +owned database. Spatial setup grants the migration role permission to SET the +no-login bbox owner; the runtime role never receives that membership. +Each supervised prerequisite command may run for 120 seconds. The owned database and BReg have 45 seconds each to answer readiness, and ThunderID discovery has a 120-second wait. A start that passes a deadline fails, stops what it acquired and keeps its owner-only @@ -129,8 +133,12 @@ deployments must bind their own destinations and signing keys. The clients file is ordinary YAML with a closed versioned format. It declares local issuer registrations; it does not add or infer BReg access profiles. -Every protected profile used by `tests/journeys.yaml` needs exactly one client -binding with scopes and claims matching the authored journey. +Every protected journey step without an exact binding needs one default client +for its profile whose scopes and claims match the ordinary step. A maintained refusal step may +use another client for the same profile by naming that exact step with +`testBindings`. The closed binding contains both `journeyId` and `stepId`; stale, +profile-mismatched, duplicate, or ambiguous bindings are refused before any +service starts. Set `accessProfiles: []` for a machine client that carries only scopes or claims for another product, such as Casework. The empty list gives that client @@ -159,6 +167,16 @@ clients: registry_principal: generic-registry-reader registry_purpose: registry-reporting registry_record_status: active + - id: reader-for-lifecycle-test + accessProfiles: [record-reader] + scopes: [registry:generic:read] + claims: + registry_principal: generic-registry-reader + registry_purpose: registry-reporting + registry_record_status: active + testBindings: + - journeyId: record-lifecycle + stepId: read-record-within-the-claim - id: source accessProfiles: [evidence-source] scopes: [registry:evidence:lookup] diff --git a/products/breg/quickstart/run.sh b/products/breg/quickstart/run.sh index 1554e672f9..33088b87c5 100755 --- a/products/breg/quickstart/run.sh +++ b/products/breg/quickstart/run.sh @@ -63,7 +63,7 @@ if [[ "$spatial" == true ]]; then import json,os,shutil,sys source=json.load(open(sys.argv[1]))['headerFile']; shutil.copyfile(source,sys.argv[2]); os.chmod(sys.argv[2],0o600) PY - python3 "$support" spatial-smoke --root "$run_dir" --seed "$repository_root/products/breg/acceptance/spatial-service-sites/fixtures/qgis-service-sites.jsonl" + python3 "$support" spatial-smoke --root "$run_dir" --seed "$repository_root/products/breg/acceptance/spatial-service-sites/fixtures/seed-service-sites.jsonl" else id=$(python3 "$support" request --root "$run_dir" --action create --code QS-001 --label 'Quickstart record') python3 "$support" request --root "$run_dir" --action get --record-id "$id" >/dev/null From 9b359d641388e03780dfb55ff43f78e89c9edf90 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 03:14:39 +0700 Subject: [PATCH 075/120] fix(dev): preserve source-backed stock issuer sessions Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 37 +- .github/scripts/test_ci_changes.py | 43 +- crates/registry-bregctl/src/dev/tests.rs | 16 +- crates/registry-casework-breg/src/config.rs | 1 + crates/registry-casework/src/task_grants.rs | 3 +- crates/registry-caseworkctl/src/dev/config.rs | 66 +- crates/registry-caseworkctl/src/dev/mod.rs | 666 ++- crates/registry-caseworkctl/src/dev/tests.rs | 4981 ++++++++--------- crates/registry-caseworkctl/src/project.rs | 4 +- crates/registry-caseworkctl/src/source_add.rs | 56 +- 10 files changed, 3084 insertions(+), 2789 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 5875f12215..43a99c5ead 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -73,8 +73,8 @@ "registry-evidence-verifier", "registry-evidencectl", ), - "mint": ("registry-mint",), "developer-tools": ( + "registry-thunderid-tooling", "registry-cli-docs", "registry-language-server", ), @@ -204,12 +204,11 @@ # Every input the Base Registry Engine tutorial gate replays or is built from. # The gate starts the quickstart launcher the page tells a reader to run, so -# the launcher and the Registry Mint key helper it reaches are inputs to the -# replay exactly as the page is: a change to either changes what a reader gets. +# the launcher and pinned issuer tooling are inputs to the replay alongside +# the page: a change to either changes what a reader gets. BREG_TUTORIAL_INPUTS = ( "Cargo.lock", "Cargo.toml", - "crates/registry-mint/demo/support/key_material.py", "docs/site/package-lock.json", "docs/site/package.json", "docs/site/scripts/check-breg-tutorial.sh", @@ -222,7 +221,7 @@ # Every input the Registry Casework tutorial gate replays or is built from. The # gate starts the all-in-one local runtime the page tells a reader to run, and -# that runtime starts Registry Mint from the project's own client declarations, +# that runtime starts stock ThunderID from the project's own client declarations, # so the page and the gate are inputs to the replay exactly as the toolset is. # The project template the page initializes is written by registry-caseworkctl, # so package routing already carries it. @@ -283,7 +282,6 @@ "crates/registry-evidence-oid4vci/src/cli.rs", ), ("crates/registry-evidencectl/src/**", "crates/registry-evidencectl/src/lib.rs"), - ("crates/registry-mint/src/cli.rs", "crates/registry-mint/src/cli.rs"), ("crates/registry-relay-v2/src/cli.rs", "crates/registry-relay-v2/src/cli.rs"), ("crates/registry-relayctl/src/**", "crates/registry-relayctl/src/lib.rs"), ("crates/registry-breg/src/cli.rs", "crates/registry-breg/src/cli.rs"), @@ -371,11 +369,10 @@ # wallet-flow test, so the adapter is deliberately not exempt. EVIDENCE_TUTORIAL_EXEMPT_PACKAGES = frozenset({"registry-evidence-client-node"}) -# The gate also builds and runs `mint`, because one tutorial serves assertions -# to a caller holding a real Mint-issued token. +# The tutorial uses the maintained stock issuer lifecycle for bearer tokens. EVIDENCE_TUTORIAL_PACKAGES = ( EVIDENCE_PACKAGES - EVIDENCE_TUTORIAL_EXEMPT_PACKAGES -) | frozenset(SHARDS["mint"]) +) | frozenset({"registry-thunderid-tooling"}) # The application tutorial imports the assembled `registry-stack-client` # wheel, which the gate builds from every product's Python binding, so a @@ -387,16 +384,17 @@ ) # The gate builds and runs exactly these: the registry, the tool that applies -# its package, and Registry Mint, because the launcher the tutorial starts +# its package, and issuer tooling, because the launcher the tutorial starts # issues the operator token the reader's first authenticated call carries. The # clients in the Base Registry Engine shard are not on the replayed path. -BREG_TUTORIAL_PACKAGES = frozenset({"registry-breg", "registry-bregctl"}) | frozenset( - SHARDS["mint"] +BREG_TUTORIAL_PACKAGES = frozenset( + {"registry-breg", "registry-bregctl", "registry-thunderid-tooling"} ) # The gate builds and runs exactly these: the Casework runtime, the tool that -# starts and seeds the local session, issuer tooling, and the Base Registry -# Engine runtime and tool used by the source-backed tutorial. +# starts and seeds the local session, and issuer tooling, because every call the +# reader makes carries a token that session issued. The clients in the Casework +# shard are not on the replayed path. CASEWORK_TUTORIAL_PACKAGES = frozenset( {"registry-casework", "registry-caseworkctl", "registry-breg", "registry-bregctl", "registry-thunderid-tooling"} ) @@ -925,7 +923,16 @@ def classify( # integration test edge, not a production runtime Cargo dependency. "breg_contracts": registry_record_cross_product or bool(affected & BREG_PACKAGES) - or "registry-evidence" in affected, + or "registry-evidence" in affected + or any( + matches( + path, + "crates/registry-casework/src/**", + "crates/registry-casework-core/src/task_grant.rs", + "crates/registry-thunderid-tooling/**", + ) + for path in paths + ), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "casework_postgres": bool(affected & CASEWORK_PACKAGES), "release_tool": release_tool, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index b2fea48c60..60175be907 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -522,7 +522,6 @@ def test_discovery_product_material_selects_the_complete_product_gate(self) -> N "developer-tools", "discovery", "evidence", - "mint", "relay-v2", "stack-client", }, @@ -855,10 +854,10 @@ def test_evidence_tutorial_routing(self) -> None: "evidence_tutorial" ] ) - # The gate runs `mint` too, so a Mint change that breaks the served + # The gate runs the stock issuer, so an issuer-tooling change that breaks the served # tutorial has to reach the job that replays it. self.assertTrue( - classify(self.workspace, ("crates/registry-mint/src/lib.rs",))[ + classify(self.workspace, ("crates/registry-thunderid-tooling/src/lib.rs",))[ "evidence_tutorial" ] ) @@ -958,7 +957,7 @@ def test_breg_tutorial_routing(self) -> None: for path in ( "products/breg/quickstart/run.sh", "products/breg/quickstart/support/quickstart.py", - "crates/registry-mint/demo/support/key_material.py", + "crates/registry-thunderid-tooling/src/local.rs", ): with self.subTest(path=path): self.assertTrue(classify(self.workspace, (path,))["breg_tutorial"]) @@ -975,7 +974,7 @@ def test_breg_tutorial_routing(self) -> None: # The launcher mints the operator token the tutorial's first # authenticated call carries. self.assertTrue( - classify(self.workspace, ("crates/registry-mint/src/lib.rs",))[ + classify(self.workspace, ("crates/registry-thunderid-tooling/src/lib.rs",))[ "breg_tutorial" ] ) @@ -994,7 +993,7 @@ def test_breg_tutorial_routing(self) -> None: # it replays neither composition page. The offline composition proof # owns the Evidence toolset and those pages' commands. A change to # registry-evidence itself still reaches the replay, because Registry - # Mint links it and the launcher issues the reader's operator token. + # Issuer tooling links it and the launcher issues the reader's operator token. for path in ( "crates/registry-evidencectl/src/source_cli.rs", "docs/site/src/content/docs/tutorials/evidence-from-breg.mdx", @@ -1042,8 +1041,9 @@ def test_casework_tutorial_routing(self) -> None: for path in infrastructure: with self.subTest(path=path): self.assertTrue(classify(self.workspace, (path,))["casework_tutorial"]) - # The replay builds both product runtimes and tools plus the stock - # issuer tooling used by their source-backed session. + # The replay builds and runs these three: the runtime the reader calls, + # the tool that starts and seeds the local session, and stock issuer tooling, + # which issues every token the reader's calls carry. for path in ( "crates/registry-casework/src/http.rs", "crates/registry-caseworkctl/src/dev/mod.rs", @@ -1193,9 +1193,9 @@ def test_evidence_code_and_product_contracts_select_its_shards_and_drift_gate(se {entry["name"] for entry in outputs["rust_matrix"]["include"]}, { "breg", + "casework", "discovery", "evidence", - "mint", "relay-v2", "developer-tools", "stack-client", @@ -1362,19 +1362,19 @@ def test_registry_record_change_runs_both_product_clients_and_facade(self) -> No self.assertTrue(RELAY_CLIENT_PACKAGES & set(outputs["rust_packages"])) self.assertLessEqual(STACK_CLIENT_PACKAGES, set(outputs["rust_packages"])) - def test_mint_change_runs_the_direct_relay_pairing_without_relay_fanout(self) -> None: - outputs = classify( - self.workspace, - ("crates/registry-mint/src/clients.rs",), - ) - self.assertIn("registry-mint", outputs["rust_packages"]) - # Relay V2 owns the real Mint-to-Relay router journey through a dev - # dependency. That test suite must run for a Mint token-profile change, - # but the test-only edge must not select Relay's normal dependents. + def test_casework_authority_changes_replay_the_stock_breg_composition(self) -> None: + for path in ("crates/registry-casework/src/task_grants.rs", "crates/registry-casework/src/auth.rs", "crates/registry-casework-core/src/task_grant.rs"): + with self.subTest(path=path): + self.assertTrue(classify(self.workspace, (path,))["breg_contracts"]) + + def test_issuer_tooling_change_runs_the_replacement_journeys(self) -> None: + outputs = classify(self.workspace, ("crates/registry-thunderid-tooling/src/local.rs",)) + self.assertIn("registry-thunderid-tooling", outputs["rust_packages"]) self.assertIn("registry-relay-v2", outputs["rust_packages"]) - self.assertNotIn("registry-relayctl", outputs["rust_packages"]) self.assertTrue(outputs["relay_v2_contracts"]) self.assertTrue(outputs["evidence_tutorial"]) + self.assertTrue(outputs["breg_tutorial"]) + self.assertTrue(outputs["casework_tutorial"]) def test_oid4vci_change_runs_rust_contracts_and_its_registered_tutorial(self) -> None: outputs = classify( @@ -1976,16 +1976,13 @@ def test_relay_docs_routing_matrix(self) -> None: for output, value in expected.items(): self.assertEqual(outputs[output], value, output) - def test_docs_job_prepares_generator_inputs_before_script_tests(self) -> None: + def test_docs_job_fetches_ignored_openapi_inputs_before_script_tests(self) -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") docs_job = workflow.split("\n docs:\n", 1)[1].split("\n docs-required:\n", 1)[0] - rust = "run: rustup toolchain install 1.95.0 --profile minimal" fetch = "run: node scripts/fetch-openapi.mjs" test_scripts = "run: npm test" - self.assertIn(rust, docs_job) self.assertIn(fetch, docs_job) - self.assertLess(docs_job.index(rust), docs_job.index(test_scripts)) self.assertLess(docs_job.index(fetch), docs_job.index(test_scripts)) def test_every_referenced_changes_output_is_declared_and_emitted(self) -> None: diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 88bba14ef9..88b86bffe6 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -220,6 +220,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_breg_access: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), + test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, }); @@ -229,15 +230,21 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_breg_access: true, scopes: vec!["registry:generic:review".into()], claims: BTreeMap::new(), + test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, }); initialize(&state.root(), &state, &clients, &files).unwrap(); let root = state.root(); - private::read(&root.join("mint/clients/guest.yaml"), MAX_BYTES) - .expect("the unbound client still registers with the local Mint"); - private::read(&root.join("mint/clients/casework-reviewer.yaml"), MAX_BYTES) - .expect("the integration client registers with the local Mint"); + let issuer = config::issuer_description(&state, &clients, &root).unwrap(); + assert!(issuer + .machine_clients + .iter() + .any(|client| client.client_id == "guest")); + assert!(issuer + .machine_clients + .iter() + .any(|client| client.client_id == "casework-reviewer")); let runtime: Value = serde_norway::from_slice( &private::read(&root.join("runtime-test.yaml"), MAX_BYTES).unwrap(), ) @@ -277,6 +284,7 @@ fn rehearsal_binding_still_resolves_each_journey_step_despite_an_unbound_client( allow_breg_access: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), + test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, }); diff --git a/crates/registry-casework-breg/src/config.rs b/crates/registry-casework-breg/src/config.rs index 82ff0786f6..78401f9e24 100644 --- a/crates/registry-casework-breg/src/config.rs +++ b/crates/registry-casework-breg/src/config.rs @@ -865,6 +865,7 @@ mod tests { .unwrap(); assert_eq!(first, changed_cadence); + } #[test] fn source_token_authority_is_explicit_bounded_and_part_of_generation() { diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 5ae4a4afef..b5d0483bc9 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -620,8 +620,7 @@ impl crate::CaseworkService { Err( registry_casework_core::SourceAdapterError::Concealed | registry_casework_core::SourceAdapterError::Denied - | registry_casework_core::SourceAdapterError::BindingMoved - | registry_casework_core::SourceAdapterError::DefinitiveRefusal, + | registry_casework_core::SourceAdapterError::BindingMoved, ) => false, Err(error) => return Err(error.into()), }; diff --git a/crates/registry-caseworkctl/src/dev/config.rs b/crates/registry-caseworkctl/src/dev/config.rs index 0216d1a6d5..7ad7dbd078 100644 --- a/crates/registry-caseworkctl/src/dev/config.rs +++ b/crates/registry-caseworkctl/src/dev/config.rs @@ -375,9 +375,14 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( ] { private::directory(&root.join(directory))?; } + let borrowed = !state.sources.is_empty(); let mut local_clients = Vec::new(); for client in &clients.clients { let directory = root.join("credentials").join(&client.id); + private::directory(&directory)?; + if borrowed { + continue; + } let public = keypair(&directory)?; private::create(&directory.join("client-id"), client.id.as_bytes())?; local_clients.push(registry_thunderid_tooling::local::LocalClient { @@ -390,21 +395,23 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( } // Stable teaching subjects are qualified by the exact local issuer URL. // The container label separately binds the randomly owned dev session. - let mut description = registry_thunderid_tooling::local::local_description( - registry_thunderid_tooling::description::SessionIdentity { - label: format!("casework-dev-{}", state.owner), - id: "casework-local".into(), - }, - state.issuer_port, - root.join("issuer"), - state.audience(), - local_clients, - )?; - if let Some(integrations) = &clients.integrations { - let policy = crate::project::load_and_check_policy(&state.project)?; - integrations.prepare(root, state, &mut description, &policy)?; + if !borrowed { + let mut description = registry_thunderid_tooling::local::local_description( + registry_thunderid_tooling::description::SessionIdentity { + label: format!("casework-dev-{}", state.owner), + id: "casework-local".into(), + }, + state.issuer_port, + root.join("issuer"), + state.audience(), + local_clients, + )?; + if let Some(integrations) = &clients.integrations { + let policy = crate::project::load_and_check_policy(&state.project)?; + integrations.prepare(root, state, &mut description, &policy)?; + } + registry_thunderid_tooling::render::render(&description)?; } - registry_thunderid_tooling::render::render(&description)?; private::create( &root.join("secrets/casework-audit-key"), hex_secret()?.as_bytes(), @@ -469,11 +476,13 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( Zeroizing::new(pem("PRIVATE KEY", &server_key.serialize_der())).as_bytes(), )?; private::create(&root.join("database/pg_hba.conf"), b"local all all trust\nhostnossl all all 0.0.0.0/0 reject\nhostnossl all all ::/0 reject\nhostssl all all 0.0.0.0/0 scram-sha-256\nhostssl all all ::/0 scram-sha-256\n")?; - let mut operator = operator(state); - if let Some(integrations) = &clients.integrations { - integrations.operator(state, clients, &mut operator)?; + if !borrowed { + let mut operator = operator(state); + if let Some(integrations) = &clients.integrations { + integrations.operator(state, clients, &mut operator)?; + } + write_yaml(&root.join("operator.yaml"), &operator)?; } - write_yaml(&root.join("operator.yaml"), &operator)?; Ok(()) } @@ -484,6 +493,25 @@ pub(super) fn prepare(root: &Path, state: &State, clients: &Clients) -> Result<( /// policy the reader's `caseworkctl check` reads. pub(super) fn operator(state: &State) -> Value { let root = state.root(); + let sources = state + .sources + .iter() + .filter_map(|(id, source)| { + source.binding.as_ref().map(|binding| (id.clone(), json!({ + "baseUrl": binding.breg_url, + "readerProfile": "casework-reader", + "tokenEndpoint": binding.token_endpoint, + "clientAssertionAudience": state.issuer_origin(), + "resource": binding.audience, + "scopes": ["casework:source-reader"], + "clientIdRef": format!("secret:file/{id}-reader-client-id"), + "clientAssertionKeyRef": format!("secret:file/{id}-reader-assertion-key.jwk"), + "webhookSecretRef": format!("secret:file/{id}-webhook-key"), + "eventSource": binding.event_source, + "reconciliationIntervalMilliseconds": 5000 + }))) + }) + .collect::>(); json!({ "apiVersion": registry_casework::RUNTIME_CONFIG_API_VERSION, "kind": registry_casework::RUNTIME_CONFIG_KIND, @@ -513,7 +541,7 @@ pub(super) fn operator(state: &State) -> Value { "path": root.join("audit/casework.ndjson"), "hashKeyRef": "secret:file/casework-audit-key" }, - "sources": {} + "sources": sources }) } diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 092cbb3a11..dd6f2a10f6 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -99,8 +99,9 @@ pub struct DevArgs { enum DevAction { /// Start or reuse the project's retained local database and services. /// - /// A resident supervisor owns this project's PostgreSQL container plus its - /// local pinned issuer container and Casework child. The database runs the pinned + /// A resident supervisor owns this project's PostgreSQL container and + /// Casework child. Standalone sessions also own a pinned issuer container; + /// --source-project sessions use the running BREG session's stock issuer. The database runs the pinned /// image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, /// which the supervisor pulls on the first start. Each supervised @@ -171,10 +172,16 @@ struct StartArgs { /// PostgreSQL loopback port on first start (default 55433; retained for restarts). #[arg(long, env = "CASEWORKCTL_DEV_DATABASE_PORT")] database_port: Option, + /// Running BREG dev project serving a declared source. This compatibility + /// bridge is available only without explicit integrations or task templates. + #[arg(long, value_name = "[ID=]PATH")] + source_project: Vec, #[arg(long, hide = true, env = "CASEWORK_BIN")] casework_bin: Option, #[arg(long, hide = true)] docker_bin: Option, + #[arg(long, hide = true, env = "BREGCTL_BIN")] + bregctl_bin: Option, } #[derive(Debug, Args)] @@ -252,6 +259,42 @@ struct State { /// to the owner. #[serde(default)] failure: Option, + #[serde(default)] + sources: BTreeMap, + #[serde(default)] + borrowed_scopes: BTreeMap>, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SourceSession { + project: PathBuf, + #[serde(default)] + binding: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SourceBinding { + breg_url: String, + token_endpoint: String, + audience: String, + event_source: String, +} + +struct StagedCredentialPair { + client_id: PathBuf, + assertion_key: PathBuf, +} +struct StagedSourceExport { + binding: SourceBinding, + reader: StagedCredentialPair, + clients: BTreeMap, + client_scopes: BTreeMap>, +} +struct StagedSourceExports { + _scratch: tempfile::TempDir, + sources: BTreeMap, } #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] @@ -307,13 +350,27 @@ impl State { format!("http://127.0.0.1:{}", self.casework_port) } fn issuer_origin(&self) -> String { - format!("http://127.0.0.1:{}", self.issuer_port) + self.borrowed() + .and_then(|binding| binding.token_endpoint.strip_suffix("/oauth2/token")) + .map(str::to_owned) + .unwrap_or_else(|| format!("http://127.0.0.1:{}", self.issuer_port)) } fn audience(&self) -> String { - self.resource - .clone() + self.borrowed() + .map(|binding| binding.audience.clone()) + .or_else(|| self.resource.clone()) .unwrap_or_else(|| format!("urn:casework:dev:{}", self.owner)) } + fn borrowed(&self) -> Option<&SourceBinding> { + self.sources + .values() + .find_map(|source| source.binding.as_ref()) + } + fn token_endpoint(&self) -> String { + self.borrowed() + .map(|binding| binding.token_endpoint.clone()) + .unwrap_or_else(|| format!("{}/oauth2/token", self.issuer_origin())) + } fn administrator(&self) -> Result<&ReportedClient> { self.clients .iter() @@ -330,9 +387,11 @@ impl State { let root = self.root(); json!({"ok":true,"command":"dev","status":self.status,"project":self.project, "stateFile":root.join("state.json"),"operatorConfig":root.join("operator.yaml"), - "caseworkUrl":self.casework_origin(),"tokenEndpoint":format!("{}/oauth2/token",self.issuer_origin()), + "caseworkUrl":self.casework_origin(),"tokenEndpoint":self.token_endpoint(), "issuer":self.issuer_origin(),"clientAssertionAudience":self.issuer_origin(),"resource":self.audience(), "audience":self.audience(),"journal":root.join("logs/casework.log"), + "sources":self.sources.iter().map(|(id,source)|(id.clone(),json!({"project":source.project, + "bregUrl":source.binding.as_ref().map(|binding|binding.breg_url.clone())}))).collect::>(), "clients":self.clients.iter().map(|client|json!({"id":client.id,"profile":client.profile,"role":client.role, "clientIdFile":root.join("credentials").join(&client.id).join("client-id"), "assertionKeyFile":root.join("credentials").join(&client.id).join("assertion-key.jwk")})).collect::>(), @@ -506,6 +565,7 @@ struct Captured { clients: Clients, digest: String, reported: Vec, + sources: BTreeMap, } /// Catch terminal interruption while the foreground command waits for its @@ -543,13 +603,48 @@ impl Drop for StartInterruption { } } +#[cfg(test)] fn capture(project: &Path, client_bytes: &[u8]) -> Result { + capture_with_sources(project, client_bytes, &[], &BTreeMap::new()) +} + +fn capture_with_sources( + project: &Path, + client_bytes: &[u8], + source_args: &[String], + retained: &BTreeMap, +) -> Result { let policy = crate::project::load_and_check_policy(project)?; let clients = config::clients(client_bytes)?; + validate_source_mode( + !policy.task_templates.is_empty(), + clients.integrations.is_some(), + !source_args.is_empty(), + )?; + let declared = policy + .sources + .iter() + .map(|source| source.id.clone()) + .collect::>(); + let sources; if let Some(integrations) = &clients.integrations { + if !source_args.is_empty() { + bail!("explicit integrations cannot be combined with --source-project"); + } + sources = BTreeMap::new(); integrations.validate(&clients, &policy)?; - } else if !policy.sources.is_empty() || !policy.task_templates.is_empty() { + } else if !policy.task_templates.is_empty() { bail!("source-backed development requires explicit integrations with source bindings and any task authority in the local clients file"); + } else { + sources = source_projects(&declared, source_args, retained)?; + if !sources.is_empty() + && policy + .access_profiles + .iter() + .any(|profile| profile.principal_claim == "sub") + { + bail!("the shared BREG issuer bridge requires explicit stable principal claims; principalClaim sub is session-qualified"); + } } let bound = config::bind(&clients, &policy)?; let reported = bound @@ -569,19 +664,490 @@ fn capture(project: &Path, client_bytes: &[u8]) -> Result { hasher.update(bytes); } for source in &policy.sources { - let bytes = bounded(&project.join(&source.description), "source description")?; + let path = crate::project::project_input_path(project, &source.description)?; + let bytes = bounded(&path, "source description")?; hasher.update((source.description.len() as u64).to_be_bytes()); hasher.update(source.description.as_bytes()); hasher.update((bytes.len() as u64).to_be_bytes()); hasher.update(bytes); } + for (id, registry) in &sources { + for bytes in [id.as_bytes(), registry.as_os_str().as_encoded_bytes()] { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + } Ok(Captured { clients, digest: config::hex_lower(&hasher.finalize()), reported, + sources, + }) +} + +fn validate_source_mode( + has_tasks: bool, + explicit_integrations: bool, + borrowed_sources: bool, +) -> Result<()> { + if explicit_integrations && borrowed_sources { + bail!("explicit integrations cannot be combined with --source-project"); + } + if has_tasks && !explicit_integrations { + bail!("task templates require explicit integrations and task authority; --source-project cannot borrow that authority"); + } + Ok(()) +} + +fn source_projects( + declared: &[String], + arguments: &[String], + retained: &BTreeMap, +) -> Result> { + let mut sources = BTreeMap::new(); + for argument in arguments { + let (id, path) = match argument.split_once('=') { + Some((id, path)) if !id.is_empty() && !path.contains('=') => (id.to_owned(), path), + Some(_) => bail!("--source-project takes ID=PATH, or PATH alone"), + None => match declared { + [only] => (only.clone(), argument.as_str()), + _ => bail!("name each registry as --source-project ID=PATH"), + }, + }; + if !declared.contains(&id) || sources.contains_key(&id) { + bail!("--source-project names an unknown or repeated source {id}"); + } + sources.insert(id, project(Path::new(path))?); + } + for id in declared { + if !sources.contains_key(id) { + let prior = retained + .get(id) + .with_context(|| format!("source {id} needs --source-project {id}=PATH"))?; + sources.insert(id.clone(), prior.project.clone()); + } + } + Ok(sources) +} + +fn bind_sources(bregctl: &Path, state: &mut State, clients: &Clients) -> Result<()> { + export_sources_inner(bregctl, state, clients, true)?; + let root = state.root(); + private::replace( + &root.join("operator.yaml"), + serde_norway::to_string(&config::operator(state))?.as_bytes(), + ) +} + +/// Export the Casework reader and every local client from each source's +/// registry session into private scratch space. Only after every report names +/// one shared issuer and every source exports the same Casework client pairs +/// are the retained copies replaced. Source readers remain source-specific. +#[cfg(test)] +fn export_sources(bregctl: &Path, state: &mut State, clients: &Clients) -> Result<()> { + export_sources_inner(bregctl, state, clients, false) +} + +fn export_sources_inner( + bregctl: &Path, + state: &mut State, + clients: &Clients, + verify_issuer: bool, +) -> Result<()> { + let root = state.root(); + let scratch = tempfile::Builder::new() + .prefix(".source-export-") + .tempdir_in(&root) + .context("creating private source export directory")?; + fs::set_permissions(scratch.path(), fs::Permissions::from_mode(0o700))?; + private::check(scratch.path(), true)?; + let mut staged = BTreeMap::new(); + for (index, (id, source)) in state.sources.iter().enumerate() { + let stage = scratch.path().join(format!("source-{index}")); + private::directory(&stage)?; + let event_source = event_source(&source.project)?; + let reader = StagedCredentialPair { + client_id: stage.join("reader-client-id"), + assertion_key: stage.join("reader-assertion-key.jwk"), + }; + let report = export_client( + bregctl, + &root, + &source.project, + "casework-reader", + &reader.client_id, + &reader.assertion_key, + )?; + let binding = source_binding(&report, event_source.clone())?; + if exact_scopes(&report)? != ["casework:source-reader"] { + bail!("the registry export for the Casework source reader must contain exactly casework:source-reader"); + } + let mut exported_clients = BTreeMap::new(); + let mut client_scopes = BTreeMap::new(); + for (client_index, client) in clients.clients.iter().enumerate() { + let pair = StagedCredentialPair { + client_id: stage.join(format!("client-{client_index}-id")), + assertion_key: stage.join(format!("client-{client_index}-assertion-key.jwk")), + }; + let report = export_client( + bregctl, + &root, + &source.project, + &client.id, + &pair.client_id, + &pair.assertion_key, + )?; + if source_binding(&report, event_source.clone())? != binding { + bail!( + "the registry session serving source {id} changed while exporting Casework client {}; retry after its local session is stable", + client.id + ); + } + let scopes = exact_scopes(&report)?; + if scopes.is_empty() + || scopes.len() > 32 + || scopes.iter().collect::>().len() != scopes.len() + || !client.scopes.iter().all(|scope| scopes.contains(scope)) + { + bail!("the registry export for Casework client {} must contain its exact bounded Casework scopes", client.id); + } + exported_clients.insert(client.id.clone(), pair); + client_scopes.insert(client.id.clone(), scopes); + } + staged.insert( + id.clone(), + StagedSourceExport { + binding, + reader, + clients: exported_clients, + client_scopes, + }, + ); + } + let staged = StagedSourceExports { + _scratch: scratch, + sources: staged, + }; + // One issuer serves the session, so every registry must share one stock issuer. + let mut issuers = staged + .sources + .values() + .map(|source| (&source.binding.token_endpoint, &source.binding.audience)); + let first = issuers.next(); + if issuers.any(|issuer| Some(issuer) != first) { + bail!("the registry sessions serving this project's sources use different local issuers; a local session borrows exactly one issuer"); + } + let issuer = staged.sources.values().next().map(|source| { + source + .binding + .token_endpoint + .strip_suffix("/oauth2/token") + .expect("source binding validated the stock token endpoint") + }); + let issuer_jwks = if verify_issuer { + issuer.map(issuer_keys).transpose()? + } else { + None + }; + if verify_issuer { + for (id, source) in &staged.sources { + if !matches!( + http_with_timeout( + "GET", + &format!("{}/ready", source.binding.breg_url), + None, + &[], + None, + HTTP_TIMEOUT, + None + ), + Ok((200, _)) + ) { + bail!("the registry serving source {id} is not answering; start its retained bregctl dev session first"); + } + } + } + let mut sources = staged.sources.iter(); + if let Some((first_id, first)) = sources.next() { + for (id, source) in sources { + for client in &clients.clients { + let first_pair = &first.clients[&client.id]; + let pair = &source.clients[&client.id]; + if !credential_pairs_match(first_pair, pair)? { + bail!( + "the registry sessions serving sources {first_id} and {id} export different credentials for Casework client {}; every source must share one stock issuer client registration", + client.id + ); + } + if first.client_scopes[&client.id] != source.client_scopes[&client.id] { + bail!("the registry sessions serving sources {first_id} and {id} export different scopes for Casework client {}", client.id); + } + } + } + } + let secrets = root.join("secrets"); + if let Some(keys) = issuer_jwks { + private::replace(&secrets.join("issuer-jwks"), &keys)?; + } + for (id, source) in &staged.sources { + publish_credential_pair( + &source.reader, + &secrets.join(format!("{id}-reader-client-id")), + &secrets.join(format!("{id}-reader-assertion-key.jwk")), + )?; + // The registry session routes its events to its own receiver; this + // key authenticates the receiver Casework publishes regardless. + let webhook = secrets.join(format!("{id}-webhook-key")); + if !webhook.exists() { + private::create(&webhook, config::hex_secret()?.as_bytes())?; + } + } + if let Some(source) = staged.sources.values().next() { + state.borrowed_scopes = source.client_scopes.clone(); + for client in &clients.clients { + let directory = root.join("credentials").join(&client.id); + publish_credential_pair( + &source.clients[&client.id], + &directory.join("client-id"), + &directory.join("assertion-key.jwk"), + )?; + } + } + for (id, source_export) in staged.sources { + if let Some(source) = state.sources.get_mut(&id) { + source.binding = Some(source_export.binding); + } + } + state.save() +} + +fn credential_pairs_match( + first: &StagedCredentialPair, + other: &StagedCredentialPair, +) -> Result { + let first_id = Zeroizing::new(private::read(&first.client_id, MAX_BYTES)?); + let other_id = Zeroizing::new(private::read(&other.client_id, MAX_BYTES)?); + let first_key = Zeroizing::new(private::read(&first.assertion_key, MAX_BYTES)?); + let other_key = Zeroizing::new(private::read(&other.assertion_key, MAX_BYTES)?); + Ok(*first_id == *other_id && *first_key == *other_key) +} + +fn publish_credential_pair( + staged: &StagedCredentialPair, + client_id: &Path, + assertion_key: &Path, +) -> Result<()> { + let id = Zeroizing::new(private::read(&staged.client_id, MAX_BYTES)?); + let key = Zeroizing::new(private::read(&staged.assertion_key, MAX_BYTES)?); + private::replace(client_id, &id)?; + private::replace(assertion_key, &key) +} + +fn source_binding(report: &Value, event_source: String) -> Result { + let text = |field: &str| -> Result { + report[field] + .as_str() + .map(str::to_owned) + .with_context(|| format!("bregctl dev export-client reported no {field}")) + }; + let token_endpoint = text("tokenEndpoint")?; + let issuer = token_endpoint.strip_suffix("/oauth2/token").context( + "bregctl dev export-client tokenEndpoint must use the stock issuer /oauth2/token endpoint", + )?; + let audience = text("audience")?; + if report["clientAssertionAudience"] != issuer || report["resource"] != audience { + bail!("bregctl dev export-client must report one exact issuer assertion audience and BREG resource"); + } + Ok(SourceBinding { + breg_url: text("bregUrl")?, + token_endpoint, + audience, + event_source, }) } +fn exact_scopes(report: &Value) -> Result> { + report["scopes"] + .as_array() + .context("bregctl dev export-client reported no exact scopes")? + .iter() + .map(|scope| { + scope + .as_str() + .map(str::to_owned) + .context("bregctl dev export-client reported a non-string scope") + }) + .collect() +} + +/// Refuse an idempotent start when a registry project now names a different +/// retained BReg session. Its issuer audience and keys are part of the running +/// Casework process, so replacing them requires an explicit Casework restart. +fn require_active_source_bindings(bregctl: &Path, state: &State) -> Result<()> { + let scratch = tempfile::Builder::new() + .prefix(".active-source-check-") + .tempdir_in(state.root()) + .context("creating private active-source check directory")?; + fs::set_permissions(scratch.path(), fs::Permissions::from_mode(0o700))?; + private::check(scratch.path(), true)?; + let clients: Clients = serde_json::from_slice(&private::read( + &state.root().join("clients.json"), + MAX_BYTES, + )?)?; + for (id, source) in &state.sources { + let stage = scratch.path().join(id); + private::directory(&stage)?; + let staged_reader = StagedCredentialPair { + client_id: stage.join("reader-id"), + assertion_key: stage.join("reader-key"), + }; + let report = export_client( + bregctl, + &state.root(), + &source.project, + "casework-reader", + &staged_reader.client_id, + &staged_reader.assertion_key, + )?; + let current = source_binding(&report, event_source(&source.project)?)?; + if source.binding.as_ref() != Some(¤t) + || exact_scopes(&report)? != ["casework:source-reader"] + { + bail!( + "the active local development session still uses an earlier BReg session for source {id}; stop Casework and start it again to bind the current registry issuer and credentials" + ); + } + let retained_reader = StagedCredentialPair { + client_id: state + .root() + .join("secrets") + .join(format!("{id}-reader-client-id")), + assertion_key: state + .root() + .join("secrets") + .join(format!("{id}-reader-assertion-key.jwk")), + }; + if !credential_pairs_match(&staged_reader, &retained_reader)? { + bail!("the active session retains an earlier BREG reader registration; stop Casework and start it again"); + } + for client in &clients.clients { + let staged = StagedCredentialPair { + client_id: stage.join(format!("{}-id", client.id)), + assertion_key: stage.join(format!("{}-key", client.id)), + }; + let report = export_client( + bregctl, + &state.root(), + &source.project, + &client.id, + &staged.client_id, + &staged.assertion_key, + )?; + let retained = StagedCredentialPair { + client_id: state + .root() + .join("credentials") + .join(&client.id) + .join("client-id"), + assertion_key: state + .root() + .join("credentials") + .join(&client.id) + .join("assertion-key.jwk"), + }; + let scopes = exact_scopes(&report)?; + if !credential_pairs_match(&staged, &retained)? + || state.borrowed_scopes.get(&client.id) != Some(&scopes) + { + bail!("the active session retains an earlier BREG client registration; stop Casework and start it again"); + } + } + } + Ok(()) +} + +/// Export one client pair from a registry session into prepared scratch paths. +fn export_client( + bregctl: &Path, + root: &Path, + registry: &Path, + client: &str, + client_id_file: &Path, + assertion_key_file: &Path, +) -> Result { + let bytes = command( + Command::new(bregctl) + .args(["--format", "json", "dev", "export-client"]) + .arg(registry) + .arg("--client") + .arg(client) + .arg("--client-id-file") + .arg(client_id_file) + .arg("--assertion-key-file") + .arg(assertion_key_file), + root, + "export-client", + None, + )?; + let report: Value = serde_json::from_slice(&bytes) + .context("bregctl dev export-client returned no JSON report")?; + if report["ok"] != true { + bail!("bregctl dev export-client did not report success for client {client}"); + } + if report["client"] != client { + bail!("bregctl dev export-client reported a different client than {client}"); + } + for path in [client_id_file, assertion_key_file] { + private::check(path, false)?; + } + Ok(report) +} + +/// The event source a registry names for itself, from its authored registry.yaml. +fn event_source(registry: &Path) -> Result { + let authored: Value = + serde_norway::from_slice(&bounded(®istry.join("registry.yaml"), "registry.yaml")?) + .context("registry.yaml must parse")?; + let id = authored["registry"]["id"] + .as_str() + .context("registry.yaml declares no registry.id")?; + let instance = authored["package"]["instanceId"] + .as_str() + .context("registry.yaml declares no package.instanceId")?; + Ok(format!( + "urn:registrystack:registry:{id}:instance:{instance}" + )) +} + +/// The published keys of the borrowed issuer, located through its discovery +/// document and accepted only from the issuer's own origin. +fn issuer_keys(issuer: &str) -> Result> { + let (status, discovery) = http_with_timeout( + "GET", + &format!("{issuer}/.well-known/openid-configuration"), + None, + &[], + None, + HTTP_TIMEOUT, + None, + ) + .with_context(|| format!("the registry issuer at {issuer} is not answering"))?; + if status != 200 || discovery["issuer"] != issuer { + bail!("the registry issuer at {issuer} publishes no discovery document for itself"); + } + let jwks_uri = discovery["jwks_uri"] + .as_str() + .filter(|uri| uri.starts_with(&format!("{issuer}/"))) + .with_context(|| { + format!("the registry issuer at {issuer} names no key document of its own") + })?; + let (status, keys) = http_with_timeout("GET", jwks_uri, None, &[], None, HTTP_TIMEOUT, None)?; + if status != 200 || keys["keys"].as_array().is_none_or(|keys| keys.is_empty()) { + bail!("the registry issuer at {issuer} publishes no keys"); + } + Ok(serde_json::to_vec(&keys)?) +} + fn start(args: StartArgs) -> Result { let project = project(&args.project)?; let parent = parent_directory(&project)?; @@ -594,11 +1160,22 @@ fn start(args: StartArgs) -> Result { }; let clients_file = clients_file(args.clients_file.as_deref(), existing.as_ref(), &project)?; let client_bytes = bounded(&clients_file, "clients file")?; + let no_sources = BTreeMap::new(); + let retained_sources = existing + .as_ref() + .map(|state| &state.sources) + .unwrap_or(&no_sources); let Captured { clients, digest, reported, - } = capture(&project, &client_bytes)?; + sources, + } = capture_with_sources( + &project, + &client_bytes, + &args.source_project, + retained_sources, + )?; // The source pin protects the records a session retains. Once `dev stop // --remove` has discarded them, changed inputs start a fresh session on // the ports and clients file the previous one used. @@ -624,6 +1201,10 @@ fn start(args: StartArgs) -> Result { if args.clients_file.is_some() && state.clients_file != clients_file { bail!("the active local development session still uses {}; stop it before selecting a different --clients-file path", state.clients_file.display()); } + if !state.sources.is_empty() { + let bregctl = executable("bregctl", args.bregctl_bin.as_deref())?; + require_active_source_bindings(&bregctl, &state)?; + } return Ok(state.report()); } // A live owner lock is conclusive even when its control socket is not ready. @@ -670,9 +1251,25 @@ fn start(args: StartArgs) -> Result { directory_teams: 0, binaries: BTreeMap::new(), failure: None, + sources: sources + .into_iter() + .map(|(id, project)| { + ( + id, + SourceSession { + project, + binding: None, + }, + ) + }) + .collect(), + borrowed_scopes: BTreeMap::new(), }; ports(state.casework_port, state.issuer_port, state.database_port)?; - for port in [state.casework_port, state.issuer_port, state.database_port] { + for port in std::iter::once(state.casework_port) + .chain(state.sources.is_empty().then_some(state.issuer_port)) + .chain(std::iter::once(state.database_port)) + { probe(port)?; } if let Some(integrations) = &clients.integrations { @@ -690,6 +1287,11 @@ fn start(args: StartArgs) -> Result { } let casework = executable("casework", args.casework_bin.as_deref())?; let docker = executable("docker", args.docker_bin.as_deref())?; + let bregctl = if state.sources.is_empty() { + None + } else { + Some(executable("bregctl", args.bregctl_bin.as_deref())?) + }; // Identify the prerequisites before the session stops a container or // launches the supervisor: a casework from another release has to // be named here, while the terminal that asked for the start is reading. @@ -697,8 +1299,18 @@ fn start(args: StartArgs) -> Result { ("casework".into(), binary(&root, &casework)?), ("docker".into(), binary(&root, &docker)?), ]); + if let Some(bregctl) = &bregctl { + state + .binaries + .insert("bregctl".into(), binary(&root, bregctl)?); + } matching_versions(&state.binaries)?; - for port in [state.casework_port, state.issuer_port] { + if let Some(bregctl) = &bregctl { + bind_sources(bregctl, &mut state, &clients)?; + } + for port in std::iter::once(state.casework_port) + .chain(state.sources.is_empty().then_some(state.issuer_port)) + { probe(port)?; } // Verify the container before accepting a retained database port. @@ -848,7 +1460,9 @@ fn stop(project_path: &Path, remove: bool, docker_bin: Option<&Path>) -> Result< // No PID-based recovery: unrelated reused PIDs must never be signalled. let _supervisor_lock = completed_supervisor_lock(&root, &state.status)?; if service_ports_must_be_free(&state.status) { - for port in [state.casework_port, state.issuer_port] { + for port in std::iter::once(state.casework_port) + .chain(state.sources.is_empty().then_some(state.issuer_port)) + { probe(port)?; } } @@ -1201,7 +1815,9 @@ fn run_supervisor_inner(args: SupervisorArgs) -> Result<()> { } database(&args.docker_bin, &mut state, &terminate)?; ensure_active(&terminate)?; - issuer(&args.docker_bin, &state, &terminate)?; + if state.sources.is_empty() { + issuer(&args.docker_bin, &state, &terminate)?; + } ensure_active(&terminate)?; // Migrations are idempotent and guarded by an advisory lock. Run them // on every start so a retained database is upgraded with the binaries @@ -3070,6 +3686,9 @@ fn issuer(docker: &Path, state: &State, terminate: &AtomicBool) -> Result<()> { } fn stop_issuer(docker: &Path, state: &State) -> Result<()> { + if !state.sources.is_empty() { + return Ok(()); + } let state_root = state.root().join("issuer"); if !state_root.join("session.json").exists() { return Ok(()); @@ -3125,7 +3744,14 @@ fn token(state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { .clients .iter() .find(|client| client.id == id) - .map(|client| (state.audience(), client.scopes.clone())) + .map(|client| { + let scopes = if state.sources.is_empty() { + client.scopes.clone() + } else { + state.borrowed_scopes.get(id).cloned().unwrap_or_default() + }; + (state.audience(), scopes) + }) .or_else(|| { clients .integrations @@ -3143,14 +3769,10 @@ fn token(state: &State, id: &str, terminate: &AtomicBool) -> Result<()> { let key = registry_platform_crypto::PrivateJwk::parse(&text) .map_err(|_| anyhow::anyhow!("the retained client key is unusable"))?; let provider = PrivateKeyJwt::new( - PrivateKeyJwtConfig::new( - format!("{}/oauth2/token", state.issuer_origin()).parse()?, - id, - key, - ) - .with_audience(state.issuer_origin()) - .with_resource(resource) - .with_scopes(scopes), + PrivateKeyJwtConfig::new(state.token_endpoint().parse()?, id, key) + .with_audience(state.issuer_origin()) + .with_resource(resource) + .with_scopes(scopes), )?; let value = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/crates/registry-caseworkctl/src/dev/tests.rs b/crates/registry-caseworkctl/src/dev/tests.rs index 6e806a9d46..5707657fae 100644 --- a/crates/registry-caseworkctl/src/dev/tests.rs +++ b/crates/registry-caseworkctl/src/dev/tests.rs @@ -28,6 +28,7 @@ fn session(project: &Path) -> State { binaries: BTreeMap::new(), failure: None, sources: BTreeMap::new(), + borrowed_scopes: BTreeMap::new(), } } @@ -489,7 +490,7 @@ fn generated_operator_config_loads_through_the_runtime_contract() { fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); fs::set_permissions(&session_root, fs::Permissions::from_mode(0o700)).unwrap(); let path = session_root.join("operator.yaml"); - config::write_yaml(&path, &config::operator(&state).unwrap()).unwrap(); + config::write_yaml(&path, &config::operator(&state)).unwrap(); let config = RuntimeConfig::load(&path).unwrap(); assert_eq!( @@ -542,3067 +543,2723 @@ fn generated_operator_config_loads_through_the_runtime_contract() { } #[test] -fn a_project_declaring_sources_needs_a_registry_project_for_each() { +fn a_project_declaring_sources_is_refused_before_anything_starts() { let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); crate::project::init(&project, "professional-review").unwrap(); - describe_professional_register(&project); let clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let refusal = format!( - "{:#}", - capture(&project, &clients, &[], &BTreeMap::new()).unwrap_err() - ); - assert!(refusal.contains("professional-register"), "{refusal}"); - assert!(refusal.contains("--source-project"), "{refusal}"); + let refusal = format!("{:#}", capture(&project, &clients).unwrap_err()); + assert!(refusal.contains("source"), "{refusal}"); +} +#[test] +fn borrowed_source_mode_is_explicit_pinned_and_refuses_session_qualified_subjects() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let policy = crate::project::load_and_check_policy(&project).unwrap(); + let description = project.join(&policy.sources[0].description); + fs::create_dir_all(description.parent().unwrap()).unwrap(); + fs::write(&description, b"synthetic source description").unwrap(); let registry = root.path().join("registry"); fs::create_dir(®istry).unwrap(); - let canonical = fs::canonicalize(®istry).unwrap(); - let explicit = [registry.display().to_string()]; - let captured = capture(&project, &clients, &explicit, &BTreeMap::new()).unwrap(); - assert_eq!(captured.sources["professional-register"], canonical); - - // The digest pins the registry project beside the policy and the clients. - let other = root.path().join("other"); + let clients = fs::read(project.join("dev-clients.yaml")).unwrap(); + let source = [registry.display().to_string()]; + let first = capture_with_sources(&project, &clients, &source, &BTreeMap::new()).unwrap(); + let other = root.path().join("other-registry"); fs::create_dir(&other).unwrap(); let moved = [other.display().to_string()]; assert_ne!( - captured.digest, - capture(&project, &clients, &moved, &BTreeMap::new()) + first.digest, + capture_with_sources(&project, &clients, &moved, &BTreeMap::new()) .unwrap() .digest ); + let policy_path = project.join("casework.yaml"); + let changed = fs::read_to_string(&policy_path) + .unwrap() + .replace("principalClaim: registry_principal", "principalClaim: sub"); + fs::write(policy_path, changed).unwrap(); + let refusal = capture_with_sources(&project, &clients, &source, &BTreeMap::new()) + .unwrap_err() + .to_string(); + assert!(refusal.contains("session-qualified"), "{refusal}"); +} - // A restart names nothing and keeps the retained registry project. - let retained = BTreeMap::from([( - "professional-register".to_string(), - SourceSession { - project: canonical.clone(), - binding: None, - }, - )]); - let restarted = capture(&project, &clients, &[], &retained).unwrap(); - assert_eq!(restarted.sources["professional-register"], canonical); - assert_eq!(restarted.digest, captured.digest); +#[test] +fn task_templates_cannot_use_the_borrowed_source_issuer() { + let refusal = validate_source_mode(true, false, true) + .unwrap_err() + .to_string(); + assert!( + refusal.contains("task templates require explicit integrations"), + "{refusal}" + ); + assert!(validate_source_mode(true, true, false).is_ok()); } #[test] -fn source_backed_sub_principals_use_the_borrowed_breg_mint_namespace() { +fn the_source_digest_pins_the_project_and_its_clients() { let root = tempfile::tempdir().unwrap(); - let project = root.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - describe_professional_register(&project); - let policy_path = project.join("casework.yaml"); - let policy = fs::read_to_string(&policy_path) - .unwrap() - .replace("principalClaim: registry_principal", "principalClaim: sub"); - fs::write(&policy_path, policy).unwrap(); - let registry = root.path().join("registry"); - fs::create_dir(®istry).unwrap(); + let project = standalone(root.path()); let clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let source = [registry.display().to_string()]; + let first = capture(&project, &clients).unwrap().digest; + assert_eq!(first, capture(&project, &clients).unwrap().digest); - let captured = capture(&project, &clients, &source, &BTreeMap::new()).unwrap(); + let edited = format!("{STANDALONE_DEV_CLIENTS}\n"); + assert_ne!(first, capture(&project, edited.as_bytes()).unwrap().digest); - for client in &captured.reported { - assert_eq!(client.principal, format!("urn:breg:dev:{}", client.id)); - } + fs::write( + project.join("casework.yaml"), + STANDALONE_YAML.replace("Decisions awaiting review", "Decisions"), + ) + .unwrap(); + assert_ne!(first, capture(&project, &clients).unwrap().digest); } #[test] -fn source_projects_are_named_by_source_when_the_project_declares_several() { - let root = tempfile::tempdir().unwrap(); - let registry = root.path().join("registry"); - fs::create_dir(®istry).unwrap(); - let path = registry.display().to_string(); - let one = ["licences".to_string()]; - let two = ["licences".to_string(), "holdings".to_string()]; - let none = BTreeMap::new(); - - let bound = source_projects(&one, std::slice::from_ref(&path), &none).unwrap(); - assert_eq!(bound["licences"], fs::canonicalize(®istry).unwrap()); - let bound = source_projects(&one, &[format!("licences={path}")], &none).unwrap(); - assert_eq!(bound["licences"], fs::canonicalize(®istry).unwrap()); - - let refusal = format!( - "{:#}", - source_projects(&two, std::slice::from_ref(&path), &none).unwrap_err() - ); - assert!( - refusal.contains("licences") && refusal.contains("holdings"), - "{refusal}" +fn redaction_hides_every_run_of_a_secret() { + let secret = b"pa55word-long-enough"; + let hidden = redact( + b"psql: password authentication failed for pa55word-long-enough", + secret, ); + let hidden = String::from_utf8(hidden).unwrap(); + assert!(!hidden.contains("pa55word"), "{hidden}"); + assert!(hidden.contains("[redacted]"), "{hidden}"); + assert!(hidden.starts_with("psql: "), "{hidden}"); +} + +#[test] +fn version_comparison_refuses_a_mismatched_runtime() { + let own = registry_platform_buildinfo::DISPLAY_VERSION; + let binaries = |version: &str| { + BTreeMap::from([( + "casework".to_owned(), + Binary { + path: PathBuf::from("/usr/local/bin/casework"), + version: version.to_owned(), + }, + )]) + }; + matching_versions(&binaries(&format!("casework {own}"))).unwrap(); + // Nothing to compare is not a mismatch. + matching_versions(&binaries(UNREPORTED_VERSION)).unwrap(); + matching_versions(&binaries("casework")).unwrap(); let refusal = format!( "{:#}", - source_projects(&one, &[format!("other={path}")], &none).unwrap_err() + matching_versions(&binaries("casework 0.0.1-other")).unwrap_err() ); - assert!(refusal.contains("other"), "{refusal}"); - let repeated = [format!("licences={path}"), format!("licences={path}")]; - let refusal = format!("{:#}", source_projects(&one, &repeated, &none).unwrap_err()); - assert!(refusal.contains("licences"), "{refusal}"); - let missing = [format!("licences={}", root.path().join("absent").display())]; - assert!(source_projects(&one, &missing, &none).is_err()); + assert!(refusal.contains("0.0.1-other"), "{refusal}"); + assert!(refusal.contains(own), "{refusal}"); + // Docker belongs to no release of this stack. + matching_versions(&BTreeMap::from([( + "docker".to_owned(), + Binary { + path: PathBuf::from("/usr/local/bin/docker"), + version: "Docker version 28.0.0, build abcdef".to_owned(), + }, + )])) + .unwrap(); } -/// The source description `caseworkctl source add` leaves in a -/// professional-review project, reduced to what the runtime contract reads. -fn describe_professional_register(project: &Path) { - fs::create_dir_all(project.join("sources")).unwrap(); - fs::write( - project.join("sources/professional-register.json"), - serde_json::to_vec(&json!({ - "apiVersion": "registry.registrystack.org/casework-source-description/v1alpha1", - "kind": "BRegCaseworkSourceDescription", - "origin": "bregctl explain change-requests", - "authority": "none", - "sourceId": "professional-register", - "sourceRevision": "sha256:source-revision", - "request": { - "requestEntity": "scope-correction", - "requestRoute": "scope-corrections", - "reviewMode": "staged", - "stages": [{"id": "review", "approvals": 1, "excludeSubmitter": true, "excludePreviousReviewers": false}], - "fields": [], - "contractFingerprint": "sha256:contract", - "application": {"mode": "manual"} - } - })) - .unwrap(), - ) +#[test] +fn ports_must_be_three_distinct_loopback_ports() { + ports(8092, 8093, 55433).unwrap(); + assert!(ports(8092, 8092, 55433).is_err()); + assert!(ports(0, 8093, 55433).is_err()); +} + +#[test] +fn start_ports_fall_back_to_named_environment_variables() { + let casework_var = "CASEWORKCTL_DEV_CASEWORK_PORT"; + let issuer_var = "CASEWORKCTL_DEV_ISSUER_PORT"; + let database_var = "CASEWORKCTL_DEV_DATABASE_PORT"; + std::env::set_var(casework_var, "19092"); + std::env::set_var(issuer_var, "19093"); + std::env::set_var(database_var, "19099"); + + let parsed = + crate::Cli::try_parse_from(["caseworkctl", "dev", "start", "/tmp/casework-project"]) + .unwrap(); + std::env::remove_var(casework_var); + std::env::remove_var(issuer_var); + std::env::remove_var(database_var); + + let crate::Command::Dev(dev) = parsed.command else { + panic!("expected dev start"); + }; + let DevArgs { + action: Some(DevAction::Start(start)), + .. + } = *dev + else { + panic!("expected dev start"); + }; + assert_eq!(start.casework_port, Some(19092)); + assert_eq!(start.issuer_port, Some(19093)); + assert_eq!(start.database_port, Some(19099)); +} + +#[test] +fn start_ports_prefer_an_explicit_flag_over_the_environment() { + let casework_var = "CASEWORKCTL_DEV_CASEWORK_PORT"; + std::env::set_var(casework_var, "19092"); + + let parsed = crate::Cli::try_parse_from([ + "caseworkctl", + "dev", + "start", + "/tmp/casework-project", + "--casework-port", + "9100", + ]) .unwrap(); + std::env::remove_var(casework_var); + + let crate::Command::Dev(dev) = parsed.command else { + panic!("expected dev start"); + }; + let DevArgs { + action: Some(DevAction::Start(start)), + .. + } = *dev + else { + panic!("expected dev start"); + }; + assert_eq!(start.casework_port, Some(9100)); } -/// A source bound to a registry session, as `export_sources` records it. -fn bound_source(registry: &Path) -> SourceSession { - SourceSession { - project: registry.to_path_buf(), - binding: Some(SourceBinding { - breg_url: "http://127.0.0.1:8090".into(), - token_endpoint: "http://127.0.0.1:8191/token".into(), - audience: "urn:breg:dev:fixture".into(), - event_source: - "urn:registrystack:registry:professional-licences:instance:professional-licences-starter" - .into(), - }), - } +#[test] +fn bare_dev_alias_ports_also_fall_back_to_the_environment() { + let database_var = "CASEWORKCTL_DEV_DATABASE_PORT"; + std::env::set_var(database_var, "19099"); + + let parsed = + crate::Cli::try_parse_from(["caseworkctl", "dev", "/tmp/casework-project"]).unwrap(); + std::env::remove_var(database_var); + + let crate::Command::Dev(dev) = parsed.command else { + panic!("expected dev"); + }; + let DevArgs { start, .. } = *dev; + assert_eq!(start.database_port, Some(19099)); } #[test] -fn a_source_backed_session_borrows_the_registry_issuer_in_its_operator_config() { +fn events_reports_only_the_bounded_journal_tail() { let root = tempfile::tempdir().unwrap(); - let project = root.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - describe_professional_register(&project); - let mut state = session(&project); - state.sources.insert( - "professional-register".into(), - bound_source(&root.path().join("registry")), - ); - let session_root = state.root(); - fs::create_dir_all(&session_root).unwrap(); - fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(&session_root, fs::Permissions::from_mode(0o700)).unwrap(); - let path = session_root.join("operator.yaml"); - config::write_yaml(&path, &config::operator(&state).unwrap()).unwrap(); + let project = standalone(root.path()); + let logs = project.join(".casework/dev/logs"); + fs::create_dir_all(&logs).unwrap(); + for directory in [ + project.join(".casework"), + project.join(".casework/dev"), + logs.clone(), + ] { + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap(); + } + let journal = logs.join("casework.log"); + let mut written = String::new(); + for index in 0..700 { + written.push_str(&format!("event-{index:04}-{}\n", "x".repeat(500))); + } + fs::write(&journal, written).unwrap(); + fs::set_permissions(&journal, fs::Permissions::from_mode(0o600)).unwrap(); - let config = RuntimeConfig::load(&path).unwrap(); - assert_eq!(config.authentication.oidc.issuer, "http://127.0.0.1:8191"); - assert_eq!(config.authentication.oidc.audience, "urn:breg:dev:fixture"); - assert_eq!(config.authentication.oidc.scope_claim, "scope"); - assert!(matches!( - config.authentication.oidc.jwks_source, - registry_casework::OidcJwksSource::Static { ref document_ref } - if document_ref == "secret:file/mint-jwks" - )); - assert_eq!(config.sources.len(), 1); - let binding = &config.sources["professional-register"]; - assert_eq!(binding.base_url, "http://127.0.0.1:8090"); - assert_eq!(binding.reader_profile, "casework-reader"); - assert_eq!(binding.token_endpoint, "http://127.0.0.1:8191/token"); - assert_eq!( - binding.client_id_ref, - "secret:file/professional-register-reader-client-id" - ); - assert_eq!( - binding.client_assertion_key_ref, - "secret:file/professional-register-reader-assertion-key.jwk" - ); - assert_eq!( - binding.webhook_secret_ref, - "secret:file/professional-register-webhook-key" - ); - assert_eq!( - binding.event_source, - "urn:registrystack:registry:professional-licences:instance:professional-licences-starter" - ); - assert_eq!(binding.reconciliation_interval_milliseconds, 5000); - assert_eq!(state.issuer(), "http://127.0.0.1:8191"); - assert_eq!(state.token_endpoint(), "http://127.0.0.1:8191/token"); - assert_eq!(state.audience(), "urn:breg:dev:fixture"); + let result = events(&project).unwrap(); + let reported = result["events"].as_array().unwrap(); + let expected_last = format!("event-0699-{}", "x".repeat(500)); + assert!(result["truncated"].as_bool().unwrap()); + assert!(reported.len() <= 512); assert_eq!( - state.report()["sources"]["professional-register"]["bregUrl"], - "http://127.0.0.1:8090" + reported.last().and_then(Value::as_str), + Some(expected_last.as_str()) ); - assert_eq!( - state.report()["tokenEndpoint"], - "http://127.0.0.1:8191/token" + assert!( + reported + .iter() + .filter_map(Value::as_str) + .map(str::len) + .sum::() + <= 256 * 1024 ); - // An unbound source cannot be served. - state - .sources - .get_mut("professional-register") - .unwrap() - .binding = None; - assert!(config::operator(&state).is_err()); + let short = (0..600) + .map(|index| format!("short-{index:04}")) + .collect::>() + .join("\n"); + fs::write(&journal, short).unwrap(); + let limited = events(&project).unwrap(); + let reported = limited["events"].as_array().unwrap(); + assert_eq!(reported.len(), 512); + assert!(limited["truncated"].as_bool().unwrap()); + assert_eq!(reported.first().and_then(Value::as_str), Some("short-0088")); + assert_eq!(reported.last().and_then(Value::as_str), Some("short-0599")); +} + +#[test] +fn stopping_a_project_that_never_started_is_refused() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let refusal = format!("{:#}", stop(&project, false, None).unwrap_err()); + assert!(refusal.contains("nothing was stopped"), "{refusal}"); + let refusal = format!("{:#}", events(&project).unwrap_err()); + assert!(refusal.contains("nothing was stopped"), "{refusal}"); } #[test] -fn a_source_backed_session_prepares_no_local_issuer() { +fn a_first_start_without_a_clients_file_names_the_flag() { let root = tempfile::tempdir().unwrap(); let project = root.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - let mut state = session(&project); - state.sources.insert( - "professional-register".into(), - SourceSession { - project: root.path().join("registry"), - binding: None, - }, + fs::create_dir(&project).unwrap(); + let refusal = format!("{:#}", clients_file(None, None, &project).unwrap_err()); + assert!(refusal.contains("--clients-file"), "{refusal}"); + assert!(refusal.contains("dev-clients.yaml"), "{refusal}"); +} + +#[test] +fn a_stopped_session_retains_an_explicit_equivalent_clients_file() { + let root = tempfile::tempdir().unwrap(); + let project = fs::canonicalize(standalone(root.path())).unwrap(); + let original_clients = fs::read(project.join("dev-clients.yaml")).unwrap(); + let replacement = project.join("replacement-clients.yaml"); + fs::write(&replacement, &original_clients).unwrap(); + let captured = capture(&project, &original_clients).unwrap(); + let mut state = session(&project); + state.source_digest = captured.digest.clone(); + state.clients = captured.reported; + state.container_id = Some("a".repeat(64)); + state.database_ready = true; + state.migrated = true; + state.seeded.insert("decisions-team".to_owned()); + state.directory_revision = 7; + state.directory_teams = 1; + parent_directory(&project).unwrap(); + initialize(&state.root(), &state, &captured.clients).unwrap(); + + // Stop after retained-state selection, before prerequisite or service work. + assert!(start(StartArgs { + project: project.clone(), + clients_file: Some(replacement.clone()), + casework_port: None, + issuer_port: None, + database_port: None, + source_project: Vec::new(), + casework_bin: Some(project.join("missing-casework")), + docker_bin: None, + bregctl_bin: None, + }) + .is_err()); + + let retained = read_state(&state.root()).unwrap(); + assert_eq!( + retained.clients_file, + fs::canonicalize(replacement).unwrap() ); - let session_root = state.root(); - private::directory(&project.join(".casework")).unwrap(); - private::directory(&session_root).unwrap(); - let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); - config::prepare(&session_root, &state, &clients).unwrap(); + assert_eq!( + clients_file(None, Some(&retained), &project).unwrap(), + retained.clients_file + ); + assert_eq!(retained.source_digest, captured.digest); + assert_eq!(retained.owner, state.owner); + assert_eq!(retained.container_id, state.container_id); + assert!(retained.database_ready); + assert!(retained.migrated); + assert_eq!(retained.seeded, state.seeded); + assert_eq!(retained.directory_revision, 7); + assert_eq!(retained.directory_teams, 1); +} - for borrowed in [ - "mint/mint.yaml", - "credentials/issuer", - "secrets/mint-jwks", - "operator.yaml", - ] { - assert!(!session_root.join(borrowed).exists(), "{borrowed}"); - } - for client in &clients.clients { - let directory = session_root.join("credentials").join(&client.id); - assert!(directory.is_dir()); - assert_eq!( - fs::read_dir(&directory).unwrap().count(), - 0, - "{}", - client.id - ); - } - assert!(file_has_bytes( - &session_root.join("secrets/runtime-database-url") - )); - assert!(file_has_bytes(&session_root.join("tls/ca.pem"))); - assert!(file_has_bytes( - &session_root.join("secrets/casework-audit-key") - )); +#[test] +fn an_active_session_refuses_an_equivalent_clients_file_at_a_new_path() { + let root = tempfile::tempdir().unwrap(); + let project = fs::canonicalize(standalone(root.path())).unwrap(); + let original_clients = fs::read(project.join("dev-clients.yaml")).unwrap(); + let replacement = project.join("replacement-clients.yaml"); + fs::write(&replacement, &original_clients).unwrap(); + let captured = capture(&project, &original_clients).unwrap(); + let mut state = session(&project); + state.status = Status::Ready; + state.source_digest = captured.digest; + state.clients = captured.reported; + parent_directory(&project).unwrap(); + initialize(&state.root(), &state, &captured.clients).unwrap(); + let control_root = control_directory(&state.root()).unwrap(); + private::directory(&control_root).unwrap(); + let socket = control_root.join("control.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0u8; 7]; + stream.read_exact(&mut request).unwrap(); + assert_eq!(&request, b"status\n"); + stream.write_all(b"ready\n").unwrap(); + }); + + let refusal = format!( + "{:#}", + start(StartArgs { + project: project.clone(), + clients_file: Some(replacement), + casework_port: None, + issuer_port: None, + database_port: None, + source_project: Vec::new(), + casework_bin: None, + docker_bin: None, + bregctl_bin: None, + }) + .unwrap_err() + ); + server.join().unwrap(); + + assert!( + refusal.contains("active local development session"), + "{refusal}" + ); + assert!(refusal.contains("stop it"), "{refusal}"); + assert!(refusal.contains("--clients-file"), "{refusal}"); + assert_eq!( + read_state(&state.root()).unwrap().clients_file, + state.clients_file + ); + remove_socket(&state.root()).unwrap(); } -/// A retained `bregctl dev` project and a fake `bregctl` that exports any -/// client it is asked for, recording every invocation. -struct RegistrySession { +#[test] +fn the_report_names_every_local_credential_without_a_secret() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let mut state = session(&project); + state.status = Status::Ready; + state.clients = vec![ReportedClient { + id: "requester".to_owned(), + profile: "requester".to_owned(), + role: CaseworkRole::Requester, + principal: config::principal("requester"), + }]; + state.directory_revision = 1; + state.directory_teams = 1; + let report = state.report(); + assert_eq!(report["caseworkUrl"], "http://127.0.0.1:8092"); + assert_eq!( + report["tokenEndpoint"], + "http://127.0.0.1:8093/oauth2/token" + ); + assert_eq!(report["audience"], state.audience()); + assert_eq!(report["directory"]["teams"], 1); + assert_eq!(report["directory"]["revision"], 1); + assert_eq!(report["clients"][0]["id"], "requester"); + assert_eq!(report["clients"][0]["role"], "requester"); + assert_eq!( + report["clients"][0]["assertionKeyFile"], + json!(state.root().join("credentials/requester/assertion-key.jwk")) + ); + let text = report.to_string(); + assert!(!text.contains("token\":\""), "{text}"); + assert!(!text.contains("password"), "{text}"); +} + +#[test] +fn stop_control_waits_for_the_complete_sequential_cleanup_budget() { + let child_shutdowns = Duration::from_secs(35 * 2); + // A timed-out Docker prerequisite gets its own graceful child shutdown + // before the supervisor can send the final response. + let database_shutdown = CHILD_DEADLINE * 3; + assert!(control_response_deadline("stop") >= child_shutdowns + database_shutdown); + assert!(control_response_deadline("status") < control_response_deadline("stop")); +} + +#[test] +fn a_completed_session_can_be_reclaimed_after_its_ports_are_reused() { + assert!(!service_ports_must_be_free(&Status::Stopped)); + assert!(!service_ports_must_be_free(&Status::Failed)); + assert!(service_ports_must_be_free(&Status::Starting)); + assert!(service_ports_must_be_free(&Status::Ready)); + assert!(service_ports_must_be_free(&Status::Stopping)); +} + +struct DockerInventory { _root: tempfile::TempDir, executable: PathBuf, - project: PathBuf, - calls: PathBuf, - audience: PathBuf, + container: PathBuf, + volume: PathBuf, + fail_create: PathBuf, } -impl RegistrySession { - fn create_project(root: &Path, name: &str) -> PathBuf { - let project = root.join(name); - fs::create_dir(&project).unwrap(); +impl DockerInventory { + fn new(state: &State) -> Self { + let root = tempfile::tempdir().unwrap(); + let executable = root.path().join("docker"); + let container = root.path().join("container-active"); + let volume = root.path().join("volume-active"); + let fail_create = root.path().join("fail-create"); fs::write( - project.join("registry.yaml"), - serde_json::to_vec(&json!({ - "registry": {"id": "professional-licences"}, - "package": {"instanceId": "professional-licences-starter"} - })) - .unwrap(), + &executable, + br#"#!/bin/sh +set -eu +fixture=$(dirname "$0") +if [ "$1" = "ps" ]; then + if [ -f "$fixture/container-active" ]; then printf 'container\n'; fi +elif [ "$1" = "inspect" ]; then + cat "$fixture/container.json" +elif [ "$1" = "volume" ] && [ "$2" = "create" ]; then + touch "$fixture/volume-active" + cat "$fixture/volume-name" +elif [ "$1" = "volume" ] && [ "$2" = "ls" ]; then + if [ -f "$fixture/volume-active" ]; then cat "$fixture/volume-name"; fi +elif [ "$1" = "volume" ] && [ "$2" = "inspect" ]; then + cat "$fixture/volume.json" +elif [ "$1" = "create" ]; then + if [ -f "$fixture/fail-create" ]; then + printf 'injected container creation failure\n' >&2 + exit 42 + fi + touch "$fixture/container-active" + cat "$fixture/container-id" +else + printf 'unexpected fake Docker command: %s\n' "$*" >&2 + exit 43 +fi +"#, ) .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let id = "a".repeat(64); + fs::write(root.path().join("container-id"), format!("{id}\n")).unwrap(); fs::write( - project.join(".fixture-token-endpoint"), - "http://127.0.0.1:8191/token", + root.path().join("container.json"), + serde_json::to_vec(&json!([{ + "Id": id, + "Name": format!("/{}", state.container_name()), + "Config": { + "Labels": { (LABEL): state.owner.clone() }, + "Image": IMAGE, + }, + "State": { "Running": false }, + }])) + .unwrap(), ) .unwrap(); - fs::write(project.join(".fixture-audience"), "urn:breg:dev:fixture").unwrap(); - fs::write(project.join(".fixture-credential"), "shared-key").unwrap(); - fs::canonicalize(project).unwrap() - } - - fn new() -> Self { - let root = tempfile::tempdir().unwrap(); - let project = Self::create_project(root.path(), "registry"); - let executable = root.path().join("bregctl"); fs::write( - &executable, - br#"#!/bin/sh -set -eu -fixture=$(dirname "$0") -printf '%s -' "$*" >> "$fixture/calls" -project="" -client="" -id_file="" -key_file="" -while [ $# -gt 0 ]; do - case "$1" in - export-client) project=$2; shift 2;; - --client) client=$2; shift 2;; - --client-id-file) id_file=$2; shift 2;; - --assertion-key-file) key_file=$2; shift 2;; - *) shift;; - esac -done -audience=$(cat "$project/.fixture-audience") -token_endpoint=$(cat "$project/.fixture-token-endpoint") -credential=$(cat "$project/.fixture-credential") -umask 077 -printf '%s' "$client" > "$id_file" -printf '{"kty":"EC","fixture":"%s"}' "$credential" > "$key_file" -printf '{"ok":true,"command":"dev export-client","client":"%s","bregUrl":"http://127.0.0.1:8090","tokenEndpoint":"%s","audience":"%s"} -' "$client" "$token_endpoint" "$audience" -"#, + root.path().join("volume-name"), + format!("{}\n", state.volume_name()), + ) + .unwrap(); + fs::write( + root.path().join("volume.json"), + serde_json::to_vec(&json!([{ + "Name": state.volume_name(), + "Labels": { (LABEL): state.owner.clone() }, + }])) + .unwrap(), ) .unwrap(); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); - let audience = project.join(".fixture-audience"); Self { - calls: root.path().join("calls"), - audience, _root: root, executable, - project, + container, + volume, + fail_create, } } - fn add_project(&self, name: &str) -> PathBuf { - Self::create_project(self._root.path(), name) - } - - fn calls(&self) -> Vec { - fs::read_to_string(&self.calls) - .unwrap_or_default() - .lines() - .map(str::to_owned) - .collect() - } - - fn recreate(&self) { - fs::write(&self.audience, "urn:breg:dev:replacement").unwrap(); - } - - fn set_audience(project: &Path, audience: &str) { - fs::write(project.join(".fixture-audience"), audience).unwrap(); - } - - fn set_credentials(project: &Path, credential: &str) { - fs::write(project.join(".fixture-credential"), credential).unwrap(); - } -} - -fn prepare_source_export_destinations(state: &State, clients: &Clients) { - let root = state.root(); - for directory in ["credentials", "secrets"] { - private::directory(&root.join(directory)).unwrap(); - } - for client in &clients.clients { - private::directory(&root.join("credentials").join(&client.id)).unwrap(); + fn deactivate(&self) { + for path in [&self.container, &self.volume] { + if path.exists() { + fs::remove_file(path).unwrap(); + } + } } } -fn retained_credential_canaries(state: &State, clients: &Clients) -> BTreeMap> { - let root = state.root(); - let mut canaries = BTreeMap::new(); - for client in &clients.clients { - let directory = root.join("credentials").join(&client.id); - for (name, bytes) in [ - ("client-id", b"RETAINED-CLIENT-ID".as_slice()), - ( - "assertion-key.jwk", - b"RETAINED-CLIENT-ASSERTION-KEY".as_slice(), - ), - ] { - let path = directory.join(name); - private::create(&path, bytes).unwrap(); - canaries.insert(path, bytes.to_vec()); - } - } - for id in state.sources.keys() { - for (suffix, bytes) in [ - ("reader-client-id", b"RETAINED-READER-ID".as_slice()), - ( - "reader-assertion-key.jwk", - b"RETAINED-READER-ASSERTION-KEY".as_slice(), - ), - ("webhook-key", b"RETAINED-WEBHOOK-KEY".as_slice()), - ] { - let path = root.join("secrets").join(format!("{id}-{suffix}")); - private::create(&path, bytes).unwrap(); - canaries.insert(path, bytes.to_vec()); - } - } - canaries +fn persisted_session(project: &Path) -> State { + let state = session(project); + private::directory(&project.join(".casework")).unwrap(); + private::directory(&state.root()).unwrap(); + private::directory(&state.root().join("logs")).unwrap(); + state.save().unwrap(); + state } #[test] -fn binding_a_source_exports_the_reader_and_every_person_from_the_registry_session() { +fn config_change_keeps_the_owner_after_container_creation_fails() { let workspace = tempfile::tempdir().unwrap(); - let project = workspace.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - let registry = RegistrySession::new(); + let project = standalone(workspace.path()); let mut state = persisted_session(&project); - let root = state.root(); - let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); - for directory in ["credentials", "secrets"] { - private::directory(&root.join(directory)).unwrap(); - } - for client in &clients.clients { - private::directory(&root.join("credentials").join(&client.id)).unwrap(); - } - state.sources.insert( - "professional-register".into(), - SourceSession { - project: registry.project.clone(), - binding: None, - }, - ); - - export_sources(®istry.executable, &mut state, &clients).unwrap(); + let docker = DockerInventory::new(&state); + fs::write(&docker.fail_create, b"").unwrap(); - let binding = state.sources["professional-register"] - .binding - .as_ref() - .unwrap(); - assert_eq!(binding.breg_url, "http://127.0.0.1:8090"); - assert_eq!(binding.token_endpoint, "http://127.0.0.1:8191/token"); - assert_eq!(binding.audience, "urn:breg:dev:fixture"); - assert_eq!( - binding.event_source, - "urn:registrystack:registry:professional-licences:instance:professional-licences-starter" - ); - assert_eq!( - fs::read_to_string(root.join("secrets/professional-register-reader-client-id")).unwrap(), - "casework-reader" + let refusal = format!( + "{:#}", + database(&docker.executable, &mut state, &AtomicBool::new(false)).unwrap_err() ); - assert!(file_has_bytes( - &root.join("secrets/professional-register-reader-assertion-key.jwk") - )); - let webhook = - fs::read_to_string(root.join("secrets/professional-register-webhook-key")).unwrap(); - assert_eq!(webhook.len(), 64); - for client in &clients.clients { - let directory = root.join("credentials").join(&client.id); - assert_eq!( - fs::read_to_string(directory.join("client-id")).unwrap(), - client.id - ); - assert!(file_has_bytes(&directory.join("assertion-key.jwk"))); - } - let calls = registry.calls(); - assert_eq!(calls.len(), 1 + clients.clients.len(), "{calls:?}"); - let prefix = format!( - "--format json dev export-client {} --client ", - registry.project.display() + assert!(refusal.contains("create-database failed"), "{refusal}"); + let retained = read_state(&state.root()).unwrap(); + assert_eq!(retained.owner, state.owner); + assert!(retained.container_id.is_none()); + assert!(docker.volume.exists()); + assert!(!docker.container.exists()); + + let refusal = format!( + "{:#}", + discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap_err() ); assert!( - calls[0].starts_with(&format!("{prefix}casework-reader ")), - "{}", - calls[0] - ); - for client in &clients.clients { - assert!( - calls - .iter() - .any(|call| call.starts_with(&format!("{prefix}{} ", client.id))), - "{calls:?}" - ); - } - // The retained state carries the binding across restarts. - assert_eq!( - read_state(&root).unwrap().sources["professional-register"].binding, - state.sources["professional-register"].binding + refusal.contains("still owns database resources"), + "{refusal}" ); + assert_eq!(read_state(&state.root()).unwrap().owner, retained.owner); - // A restart exports the pairs again instead of refusing the retained copies. - export_sources(®istry.executable, &mut state, &clients).unwrap(); - assert_eq!(registry.calls().len(), 2 * (1 + clients.clients.len())); - assert_eq!( - webhook, - fs::read_to_string(root.join("secrets/professional-register-webhook-key")).unwrap() - ); + docker.deactivate(); + discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap(); + assert!(!state.root().exists()); } #[test] -fn incompatible_source_mints_leave_retained_credentials_unchanged() { +fn config_change_keeps_the_owner_after_created_container_cannot_be_saved() { let workspace = tempfile::tempdir().unwrap(); - let project = workspace.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - let registry = RegistrySession::new(); - let other_registry = registry.add_project("other-registry"); - RegistrySession::set_audience(&other_registry, "urn:breg:dev:other"); + let project = standalone(workspace.path()); let mut state = persisted_session(&project); - let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); - state.sources.insert( - "alpha".into(), - SourceSession { - project: registry.project.clone(), - binding: None, - }, - ); - state.sources.insert( - "beta".into(), - SourceSession { - project: other_registry, - binding: None, - }, - ); - state.save().unwrap(); - prepare_source_export_destinations(&state, &clients); - let canaries = retained_credential_canaries(&state, &clients); - let retained_state = fs::read(state.root().join("state.json")).unwrap(); + let docker = DockerInventory::new(&state); + fs::set_permissions(state.root(), fs::Permissions::from_mode(0o500)).unwrap(); + + let result = database(&docker.executable, &mut state, &AtomicBool::new(false)); + fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); + let refusal = format!("{:#}", result.unwrap_err()); + assert!(refusal.contains("cannot be created"), "{refusal}"); + let retained = read_state(&state.root()).unwrap(); + assert_eq!(retained.owner, state.owner); + assert!(retained.container_id.is_none()); + assert!(docker.volume.exists()); + assert!(docker.container.exists()); let refusal = format!( "{:#}", - export_sources(®istry.executable, &mut state, &clients).unwrap_err() + discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap_err() ); - - assert!(refusal.contains("different local Mints"), "{refusal}"); - assert_eq!( - fs::read(state.root().join("state.json")).unwrap(), - retained_state + assert!( + refusal.contains("still owns database resources"), + "{refusal}" ); - for (path, bytes) in canaries { - assert_eq!(fs::read(path).unwrap(), bytes); - } - assert!(state - .sources - .values() - .all(|source| source.binding.is_none())); + assert_eq!(read_state(&state.root()).unwrap().owner, retained.owner); + + docker.deactivate(); + discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap(); + assert!(!state.root().exists()); } #[test] -fn sources_need_the_same_shared_casework_client_credentials() { - let workspace = tempfile::tempdir().unwrap(); - let project = workspace.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - let registry = RegistrySession::new(); - let other_registry = registry.add_project("other-registry"); - RegistrySession::set_credentials(&other_registry, "other-key"); - let mut state = persisted_session(&project); - let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); - state.sources.insert( - "alpha".into(), - SourceSession { - project: registry.project.clone(), - binding: None, - }, - ); - state.sources.insert( - "beta".into(), - SourceSession { - project: other_registry, - binding: None, - }, - ); - state.save().unwrap(); - prepare_source_export_destinations(&state, &clients); - let canaries = retained_credential_canaries(&state, &clients); - let retained_state = fs::read(state.root().join("state.json")).unwrap(); +fn volume_removal_requires_the_retained_owner_label() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let state = session(&project); + let mut wrong_labels = serde_json::Map::new(); + wrong_labels.insert(LABEL.to_owned(), Value::String("another-owner".to_owned())); + let unrelated = json!({ + "Name": state.volume_name(), + "Labels": Value::Object(wrong_labels), + }); + let mut removed = false; let refusal = format!( "{:#}", - export_sources(®istry.executable, &mut state, &clients).unwrap_err() - ); - - assert!(refusal.contains("sources alpha and beta"), "{refusal}"); - assert!( - refusal.contains("different credentials for Casework client"), - "{refusal}" - ); - assert_eq!( - fs::read(state.root().join("state.json")).unwrap(), - retained_state + remove_verified_volume(&state, Some(unrelated), None, |_| { + removed = true; + Ok(()) + }) + .unwrap_err() ); - for (path, bytes) in canaries { - assert_eq!(fs::read(path).unwrap(), bytes); - } - assert!(state - .sources - .values() - .all(|source| source.binding.is_none())); -} - -#[test] -fn two_sources_can_share_one_registry_client_registration() { - let workspace = tempfile::tempdir().unwrap(); - let project = workspace.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - let registry = RegistrySession::new(); - let mut state = persisted_session(&project); - let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); - for id in ["alpha", "beta"] { - state.sources.insert( - id.into(), - SourceSession { - project: registry.project.clone(), - binding: None, - }, - ); - } - state.save().unwrap(); - prepare_source_export_destinations(&state, &clients); - - export_sources(®istry.executable, &mut state, &clients).unwrap(); - assert!(state - .sources - .values() - .all(|source| source.binding.is_some())); - for id in state.sources.keys() { - assert_eq!( - fs::read_to_string( - state - .root() - .join("secrets") - .join(format!("{id}-reader-client-id")) - ) - .unwrap(), - "casework-reader" - ); - } - for client in &clients.clients { - let directory = state.root().join("credentials").join(&client.id); - assert_eq!( - fs::read_to_string(directory.join("client-id")).unwrap(), - client.id - ); - assert_eq!( - fs::read_to_string(directory.join("assertion-key.jwk")).unwrap(), - r#"{"kty":"EC","fixture":"shared-key"}"# - ); - } - assert_eq!(registry.calls().len(), 2 * (1 + clients.clients.len())); - assert_eq!(read_state(&state.root()).unwrap().sources, state.sources); -} + assert!(refusal.contains("volume ownership differs"), "{refusal}"); + assert!(!removed); -/// A loopback issuer answering its discovery document and published keys once each. -fn fake_issuer(jwks: Value) -> (String, thread::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let origin = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); - let issuer = origin.clone(); - let server = thread::spawn(move || { - for _ in 0..2 { - let (mut stream, _) = listener.accept().unwrap(); - let mut buffer = [0u8; 4096]; - let read = stream.read(&mut buffer).unwrap(); - let request = String::from_utf8_lossy(&buffer[..read]).into_owned(); - let body = if request.starts_with("GET /.well-known/openid-configuration ") { - json!({"issuer": issuer, "jwks_uri": format!("{issuer}/keys.json")}).to_string() - } else if request.starts_with("GET /keys.json ") { - jwks.to_string() - } else { - panic!("unexpected issuer request: {request}"); - }; - write!( - stream, - "HTTP/1.1 200 OK -Content-Type: application/json -Content-Length: {} -Connection: close - -{body}", - body.len() - ) - .unwrap(); - } + let mut owned_labels = serde_json::Map::new(); + owned_labels.insert(LABEL.to_owned(), Value::String(state.owner.clone())); + let owned = json!({ + "Name": state.volume_name(), + "Labels": Value::Object(owned_labels), }); - (origin, server) + remove_verified_volume(&state, Some(owned), None, |name| { + assert_eq!(name, state.volume_name()); + removed = true; + Ok(()) + }) + .unwrap(); + assert!(removed); } -#[test] -fn the_registry_issuer_keys_are_read_through_its_discovery_document() { - let jwks = json!({"keys": [{"kty": "EC", "crv": "P-256", "kid": "k1", "x": "a", "y": "b"}]}); - let (issuer, server) = fake_issuer(jwks.clone()); - let keys = issuer_keys(&issuer).unwrap(); - server.join().unwrap(); - assert_eq!(serde_json::from_slice::(&keys).unwrap(), jwks); - - let (issuer, server) = fake_issuer(json!({"unexpected": true})); - assert!(issuer_keys(&issuer).is_err()); - server.join().unwrap(); +fn legacy_database_container(state: &State, volume_name: &str, destination: &str) -> Value { + let mut labels = serde_json::Map::new(); + labels.insert(LABEL.to_owned(), Value::String(state.owner.clone())); + json!({ + "Id": state.container_id.as_ref().unwrap(), + "Name": format!("/{}", state.container_name()), + "Config": { + "Labels": Value::Object(labels), + "Image": IMAGE, + }, + "Mounts": [{ + "Type": "volume", + "Name": volume_name, + "Destination": destination, + }], + }) } #[test] -fn the_source_digest_pins_the_project_and_its_clients() { +fn legacy_unlabeled_volume_requires_the_exact_retained_container_and_mount() { let root = tempfile::tempdir().unwrap(); let project = standalone(root.path()); - let clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let first = capture(&project, &clients, &[], &BTreeMap::new()) - .unwrap() - .digest; - assert_eq!( - first, - capture(&project, &clients, &[], &BTreeMap::new()) - .unwrap() - .digest - ); - - let edited = format!("{STANDALONE_DEV_CLIENTS}\n"); - assert_ne!( - first, - capture(&project, edited.as_bytes(), &[], &BTreeMap::new()) - .unwrap() - .digest - ); + let mut state = session(&project); + state.container_id = Some("retained-container-id".to_owned()); + let volume = json!({ + "Name": state.volume_name(), + "Labels": null, + }); + let container = + legacy_database_container(&state, &state.volume_name(), "/var/lib/postgresql/data"); + let mut removed = false; - fs::write( - project.join("casework.yaml"), - STANDALONE_YAML.replace("Decisions awaiting review", "Decisions"), - ) + remove_verified_volume(&state, Some(volume.clone()), Some(&container), |name| { + assert_eq!(name, state.volume_name()); + removed = true; + Ok(()) + }) .unwrap(); - assert_ne!( - first, - capture(&project, &clients, &[], &BTreeMap::new()) - .unwrap() - .digest + assert!(removed); + + for (container, expected) in [ + (None, "retained container is absent"), + ( + Some(legacy_database_container( + &state, + "different-volume", + "/var/lib/postgresql/data", + )), + "is not mounted", + ), + ( + Some(legacy_database_container( + &state, + &state.volume_name(), + "/different-destination", + )), + "is not mounted", + ), + ] { + removed = false; + let refusal = format!( + "{:#}", + remove_verified_volume(&state, Some(volume.clone()), container.as_ref(), |_| { + removed = true; + Ok(()) + }) + .unwrap_err() + ); + assert!(refusal.contains(expected), "{refusal}"); + assert!(!removed); + } + + let mut wrong_container = container.clone(); + wrong_container["Id"] = Value::String("different-container-id".to_owned()); + removed = false; + let refusal = format!( + "{:#}", + remove_verified_volume(&state, Some(volume), Some(&wrong_container), |_| { + removed = true; + Ok(()) + }) + .unwrap_err() ); + assert!(refusal.contains("container ownership differs"), "{refusal}"); + assert!(!removed); } #[test] -fn the_source_digest_pins_imported_source_descriptions() { - let root = tempfile::tempdir().unwrap(); - let project = root.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - describe_professional_register(&project); - let registry = root.path().join("registry"); - fs::create_dir(®istry).unwrap(); - let source = [registry.display().to_string()]; - let clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let first = capture(&project, &clients, &source, &BTreeMap::new()) - .unwrap() - .digest; +fn foreground_interruption_terminates_and_reaps_its_owned_supervisor() { + let workspace = tempfile::tempdir().unwrap(); + let project = standalone(workspace.path()); + let mut state = session(&project); + state.status = Status::Starting; + fs::create_dir_all(state.root()).unwrap(); + fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); + state.save().unwrap(); + let mut supervisor = Command::new("/bin/sh") + .args(["-c", "while :; do :; done"]) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let interrupted = AtomicBool::new(true); - let description_path = project.join("sources/professional-register.json"); - let mut description: Value = - serde_json::from_slice(&fs::read(&description_path).unwrap()).unwrap(); - description["sourceRevision"] = json!("sha256:changed-source-revision"); - fs::write(&description_path, serde_json::to_vec(&description).unwrap()).unwrap(); - assert_ne!( - first, - capture(&project, &clients, &source, &BTreeMap::new()) - .unwrap() - .digest + let refusal = format!( + "{:#}", + wait_for_start(&state.root(), &mut supervisor, &interrupted).unwrap_err() ); - fs::remove_file(&description_path).unwrap(); - assert!(capture(&project, &clients, &source, &BTreeMap::new()).is_err()); + assert!(refusal.contains("local start interrupted"), "{refusal}"); + assert!(supervisor.try_wait().unwrap().is_some()); + let retained = read_state(&state.root()).unwrap(); + assert!(matches!(retained.status, Status::Failed)); + assert!(retained + .failure + .is_some_and(|failure| failure.contains("interrupted"))); } #[test] -fn redaction_hides_every_run_of_a_secret() { - let secret = b"pa55word-long-enough"; - let hidden = redact( - b"psql: password authentication failed for pa55word-long-enough", - secret, - ); - let hidden = String::from_utf8(hidden).unwrap(); - assert!(!hidden.contains("pa55word"), "{hidden}"); - assert!(hidden.contains("[redacted]"), "{hidden}"); - assert!(hidden.starts_with("psql: "), "{hidden}"); -} +fn failed_start_waits_for_the_supervisor_lock_to_be_released() { + let workspace = tempfile::tempdir().unwrap(); + let project = standalone(workspace.path()); + let mut state = session(&project); + state.status = Status::Failed; + state.failure = Some("injected supervisor failure".to_owned()); + fs::create_dir_all(state.root()).unwrap(); + fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); + state.save().unwrap(); + let lock = private::lock(&state.root().join("supervisor.lock")).unwrap(); + let release = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + drop(lock); + }); + let mut supervisor = Command::new("/bin/sleep").arg("0.2").spawn().unwrap(); + let interrupted = AtomicBool::new(false); + let started = Instant::now(); -#[test] -fn version_comparison_refuses_a_mismatched_runtime() { - let own = registry_platform_buildinfo::DISPLAY_VERSION; - let binaries = |version: &str| { - BTreeMap::from([( - "casework".to_owned(), - Binary { - path: PathBuf::from("/usr/local/bin/casework"), - version: version.to_owned(), - }, - )]) - }; - matching_versions(&binaries(&format!("casework {own}"))).unwrap(); - // Nothing to compare is not a mismatch. - matching_versions(&binaries(UNREPORTED_VERSION)).unwrap(); - matching_versions(&binaries("casework")).unwrap(); let refusal = format!( "{:#}", - matching_versions(&binaries("casework 0.0.1-other")).unwrap_err() + wait_for_start(&state.root(), &mut supervisor, &interrupted).unwrap_err() ); - assert!(refusal.contains("0.0.1-other"), "{refusal}"); - assert!(refusal.contains(own), "{refusal}"); - // Docker belongs to no release of this stack. - matching_versions(&BTreeMap::from([( - "docker".to_owned(), - Binary { - path: PathBuf::from("/usr/local/bin/docker"), - version: "Docker version 28.0.0, build abcdef".to_owned(), - }, - )])) - .unwrap(); -} + let elapsed = started.elapsed(); + release.join().unwrap(); -#[test] -fn ports_must_be_three_distinct_loopback_ports() { - ports(8092, 8093, 55433).unwrap(); - assert!(ports(8092, 8092, 55433).is_err()); - assert!(ports(0, 8093, 55433).is_err()); + assert!(refusal.contains("injected supervisor failure"), "{refusal}"); + assert!(supervisor.try_wait().unwrap().is_some()); + assert!( + elapsed >= Duration::from_millis(150), + "elapsed: {elapsed:?}" + ); + assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); } #[test] -fn start_ports_fall_back_to_named_environment_variables() { - let casework_var = "CASEWORKCTL_DEV_CASEWORK_PORT"; - let issuer_var = "CASEWORKCTL_DEV_ISSUER_PORT"; - let database_var = "CASEWORKCTL_DEV_DATABASE_PORT"; - std::env::set_var(casework_var, "19092"); - std::env::set_var(issuer_var, "19093"); - std::env::set_var(database_var, "19099"); - - let parsed = - crate::Cli::try_parse_from(["caseworkctl", "dev", "start", "/tmp/casework-project"]) - .unwrap(); - std::env::remove_var(casework_var); - std::env::remove_var(issuer_var); - std::env::remove_var(database_var); - - let crate::Command::Dev(dev_args) = parsed.command else { - panic!("expected dev"); - }; - let DevArgs { - action: Some(DevAction::Start(start)), - .. - } = *dev_args - else { - panic!("expected dev start"); - }; - assert_eq!(start.casework_port, Some(19092)); - assert_eq!(start.issuer_port, Some(19093)); - assert_eq!(start.database_port, Some(19099)); +fn migration_failures_use_the_bounded_native_diagnostic_stream() { + let root = tempfile::tempdir().unwrap(); + private::directory(&root.path().join("logs")).unwrap(); + let refusal = format!( + "{:#}", + command( + Command::new("/bin/sh").args([ + "-c", + "printf 'casework: schema upgrade refused safely\\n' >&2; exit 1", + ]), + root.path(), + "migrate", + None, + ) + .unwrap_err() + ); + assert!( + refusal.contains("schema upgrade refused safely"), + "{refusal}" + ); + assert!(refusal.contains("owner-only diagnostics"), "{refusal}"); } #[test] -fn start_ports_prefer_an_explicit_flag_over_the_environment() { - let casework_var = "CASEWORKCTL_DEV_CASEWORK_PORT"; - std::env::set_var(casework_var, "19092"); +fn database_readiness_commands_stop_at_the_aggregate_deadline() { + let root = tempfile::tempdir().unwrap(); + private::directory(&root.path().join("logs")).unwrap(); + let started = Instant::now(); + let deadline = started + Duration::from_millis(75); + let terminate = AtomicBool::new(false); - let parsed = crate::Cli::try_parse_from([ - "caseworkctl", - "dev", - "start", - "/tmp/casework-project", - "--casework-port", - "9100", - ]) - .unwrap(); - std::env::remove_var(casework_var); + let refusal = format!( + "{:#}", + command_before_cancellable( + Command::new("/bin/sh").args(["-c", "while :; do :; done"]), + root.path(), + "database-readiness", + None, + deadline, + &terminate, + ) + .unwrap_err() + ); - let crate::Command::Dev(dev_args) = parsed.command else { - panic!("expected dev"); - }; - let DevArgs { - action: Some(DevAction::Start(start)), - .. - } = *dev_args - else { - panic!("expected dev start"); - }; - assert_eq!(start.casework_port, Some(9100)); + assert!(refusal.contains("timed out"), "{refusal}"); + assert!(started.elapsed() < Duration::from_secs(1)); } #[test] -fn bare_dev_alias_ports_also_fall_back_to_the_environment() { - let database_var = "CASEWORKCTL_DEV_DATABASE_PORT"; - std::env::set_var(database_var, "19099"); +fn interrupted_native_prerequisite_is_killed_and_reaped() { + let root = tempfile::tempdir().unwrap(); + private::directory(&root.path().join("logs")).unwrap(); + let marker = root.path().join("prerequisite.pid"); + let terminate = Arc::new(AtomicBool::new(false)); + let signal = Arc::clone(&terminate); + let marker_for_signal = marker.clone(); + let interrupter = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(1); + while !marker_for_signal.exists() { + assert!(Instant::now() < deadline, "prerequisite did not start"); + thread::sleep(Duration::from_millis(5)); + } + signal.store(true, Ordering::Relaxed); + }); + let started = Instant::now(); - let parsed = - crate::Cli::try_parse_from(["caseworkctl", "dev", "/tmp/casework-project"]).unwrap(); - std::env::remove_var(database_var); + let refusal = format!( + "{:#}", + command_cancellable( + Command::new("/bin/sh") + .arg("-c") + .arg("printf '%s' \"$$\" > \"$1\"; while :; do :; done") + .arg("prerequisite") + .arg(&marker), + root.path(), + "interruptible-prerequisite", + None, + &terminate, + ) + .unwrap_err() + ); + interrupter.join().unwrap(); + let pid = fs::read_to_string(&marker).unwrap().parse::().unwrap(); + let pid = rustix::process::Pid::from_raw(pid).unwrap(); - let crate::Command::Dev(dev_args) = parsed.command else { - panic!("expected dev"); - }; - let DevArgs { start, .. } = *dev_args; - assert_eq!(start.database_port, Some(19099)); + assert!(refusal.contains("interrupted"), "{refusal}"); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(rustix::process::test_kill_process(pid).is_err()); } #[test] -fn events_reports_only_the_bounded_journal_tail() { +fn native_pump_setup_failures_reap_the_child_and_join_started_pumps() { let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let logs = project.join(".casework/dev/logs"); - fs::create_dir_all(&logs).unwrap(); - for directory in [ - project.join(".casework"), - project.join(".casework/dev"), - logs.clone(), - ] { - fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap(); - } - let journal = logs.join("casework.log"); - let mut written = String::new(); - for index in 0..700 { - written.push_str(&format!("event-{index:04}-{}\n", "x".repeat(500))); - } - fs::write(&journal, written).unwrap(); - fs::set_permissions(&journal, fs::Permissions::from_mode(0o600)).unwrap(); - - let result = events(&project).unwrap(); - let reported = result["events"].as_array().unwrap(); - let expected_last = format!("event-0699-{}", "x".repeat(500)); - assert!(result["truncated"].as_bool().unwrap()); - assert!(reported.len() <= 512); - assert_eq!( - reported.last().and_then(Value::as_str), - Some(expected_last.as_str()) - ); - assert!( - reported - .iter() - .filter_map(Value::as_str) - .map(str::len) - .sum::() - <= 256 * 1024 - ); - - let short = (0..600) - .map(|index| format!("short-{index:04}")) - .collect::>() - .join("\n"); - fs::write(&journal, short).unwrap(); - let limited = events(&project).unwrap(); - let reported = limited["events"].as_array().unwrap(); - assert_eq!(reported.len(), 512); - assert!(limited["truncated"].as_bool().unwrap()); - assert_eq!(reported.first().and_then(Value::as_str), Some("short-0088")); - assert_eq!(reported.last().and_then(Value::as_str), Some("short-0599")); -} - -#[test] -fn stopping_a_project_that_never_started_is_refused() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let refusal = format!("{:#}", stop(&project, false, None).unwrap_err()); - assert!(refusal.contains("nothing was stopped"), "{refusal}"); - let refusal = format!("{:#}", events(&project).unwrap_err()); - assert!(refusal.contains("nothing was stopped"), "{refusal}"); -} - -#[test] -fn a_first_start_without_a_clients_file_names_the_flag() { - let root = tempfile::tempdir().unwrap(); - let project = root.path().join("project"); - fs::create_dir(&project).unwrap(); - let refusal = format!("{:#}", clients_file(None, None, &project).unwrap_err()); - assert!(refusal.contains("--clients-file"), "{refusal}"); - assert!(refusal.contains("dev-clients.yaml"), "{refusal}"); -} - -#[test] -fn a_stopped_session_retains_an_explicit_equivalent_clients_file() { - let root = tempfile::tempdir().unwrap(); - let project = fs::canonicalize(standalone(root.path())).unwrap(); - let original_clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let replacement = project.join("replacement-clients.yaml"); - fs::write(&replacement, &original_clients).unwrap(); - let captured = capture(&project, &original_clients, &[], &BTreeMap::new()).unwrap(); - let mut state = session(&project); - state.source_digest = captured.digest.clone(); - state.clients = captured.reported; - state.container_id = Some("a".repeat(64)); - state.database_ready = true; - state.migrated = true; - state.seeded.insert("decisions-team".to_owned()); - state.directory_revision = 7; - state.directory_teams = 1; - parent_directory(&project).unwrap(); - initialize(&state.root(), &state, &captured.clients).unwrap(); + private::directory(&root.path().join("logs")).unwrap(); + for fail_on in [1, 2] { + let child = Command::new("/bin/sleep") + .arg("5") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + let log = log_file(root.path(), "pump-setup").unwrap(); + let joined = Arc::new(AtomicBool::new(false)); + let mut calls = 0; + let started = Instant::now(); - // Stop after retained-state selection, before prerequisite or service work. - assert!(start(StartArgs { - project: project.clone(), - clients_file: Some(replacement.clone()), - casework_port: None, - issuer_port: None, - database_port: None, - casework_bin: Some(project.join("missing-casework")), - docker_bin: None, - source_project: Vec::new(), - bregctl_bin: None, - }) - .is_err()); + let refusal = format!( + "{:#}", + output_from_child_with_pump_spawner( + child, + NativeRun { + root: root.path(), + name: "pump-setup", + log, + input: None, + aggregate_deadline: None, + terminate: None, + }, + |_stream, task| { + calls += 1; + if calls == fail_on { + return Err(std::io::Error::other("injected pump spawn failure")); + } + let joined = Arc::clone(&joined); + thread::Builder::new().spawn(move || { + let result = task(); + joined.store(true, Ordering::Relaxed); + result + }) + }, + ) + .err() + .expect("selected pump spawn must fail") + ); - let retained = read_state(&state.root()).unwrap(); - assert_eq!( - retained.clients_file, - fs::canonicalize(replacement).unwrap() - ); - assert_eq!( - clients_file(None, Some(&retained), &project).unwrap(), - retained.clients_file - ); - assert_eq!(retained.source_digest, captured.digest); - assert_eq!(retained.owner, state.owner); - assert_eq!(retained.container_id, state.container_id); - assert!(retained.database_ready); - assert!(retained.migrated); - assert_eq!(retained.seeded, state.seeded); - assert_eq!(retained.directory_revision, 7); - assert_eq!(retained.directory_teams, 1); + let reader = if fail_on == 1 { "output" } else { "diagnostic" }; + assert!(refusal.contains(reader), "{refusal}"); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(joined.load(Ordering::Relaxed), fail_on == 2); + assert!(rustix::process::test_kill_process(pid).is_err()); + } } #[test] -fn an_active_session_refuses_an_equivalent_clients_file_at_a_new_path() { +fn failed_native_stdin_write_reaps_the_child_and_joins_pumps() { let root = tempfile::tempdir().unwrap(); - let project = fs::canonicalize(standalone(root.path())).unwrap(); - let original_clients = fs::read(project.join("dev-clients.yaml")).unwrap(); - let replacement = project.join("replacement-clients.yaml"); - fs::write(&replacement, &original_clients).unwrap(); - let captured = capture(&project, &original_clients, &[], &BTreeMap::new()).unwrap(); - let mut state = session(&project); - state.status = Status::Ready; - state.source_digest = captured.digest; - state.clients = captured.reported; - parent_directory(&project).unwrap(); - initialize(&state.root(), &state, &captured.clients).unwrap(); - let control_root = control_directory(&state.root()).unwrap(); - private::directory(&control_root).unwrap(); - let socket = control_root.join("control.sock"); - let listener = UnixListener::bind(&socket).unwrap(); - fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0u8; 7]; - stream.read_exact(&mut request).unwrap(); - assert_eq!(&request, b"status\n"); - stream.write_all(b"ready\n").unwrap(); - }); + private::directory(&root.path().join("logs")).unwrap(); + let child = Command::new("/bin/sh") + .args(["-c", "exec 0<&-; while :; do :; done"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + let log = log_file(root.path(), "closed-stdin").unwrap(); + let input = vec![b'x'; MAX_BYTES as usize]; + let stdout_joined = Arc::new(AtomicBool::new(false)); + let stderr_joined = Arc::new(AtomicBool::new(false)); + let started = Instant::now(); let refusal = format!( "{:#}", - start(StartArgs { - project: project.clone(), - clients_file: Some(replacement), - casework_port: None, - issuer_port: None, - database_port: None, - casework_bin: None, - docker_bin: None, - source_project: Vec::new(), - bregctl_bin: None, - }) - .unwrap_err() + output_from_child_with_pump_spawner( + child, + NativeRun { + root: root.path(), + name: "closed-stdin", + log, + input: Some(Input { + bytes: &input, + secret: None, + }), + aggregate_deadline: None, + terminate: None, + }, + |stream, task| { + let joined = if stream == "stdout" { + Arc::clone(&stdout_joined) + } else { + Arc::clone(&stderr_joined) + }; + thread::Builder::new().spawn(move || { + let result = task(); + joined.store(true, Ordering::Relaxed); + result + }) + }, + ) + .err() + .expect("closed stdin must refuse the input") ); - server.join().unwrap(); assert!( - refusal.contains("active local development session"), + refusal.contains("write native prerequisite input"), "{refusal}" ); - assert!(refusal.contains("stop it"), "{refusal}"); - assert!(refusal.contains("--clients-file"), "{refusal}"); - assert_eq!( - read_state(&state.root()).unwrap().clients_file, - state.clients_file - ); - remove_socket(&state.root()).unwrap(); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(stdout_joined.load(Ordering::Relaxed)); + assert!(stderr_joined.load(Ordering::Relaxed)); + assert!(rustix::process::test_kill_process(pid).is_err()); } #[test] -fn an_active_source_backed_session_refuses_a_recreated_registry_session() { - let workspace = tempfile::tempdir().unwrap(); - let project = workspace.path().join("project"); - crate::project::init(&project, "professional-review").unwrap(); - describe_professional_register(&project); - let project = fs::canonicalize(project).unwrap(); - let registry = RegistrySession::new(); - let clients_bytes = fs::read(project.join("dev-clients.yaml")).unwrap(); - let source_argument = [registry.project.display().to_string()]; - let captured = capture(&project, &clients_bytes, &source_argument, &BTreeMap::new()).unwrap(); - let mut state = session(&project); - state.status = Status::Ready; - state.source_digest = captured.digest; - state.clients = captured.reported; - state.sources.insert( - "professional-register".into(), - bound_source(®istry.project), - ); - parent_directory(&project).unwrap(); - initialize(&state.root(), &state, &captured.clients).unwrap(); - let control_root = control_directory(&state.root()).unwrap(); - private::directory(&control_root).unwrap(); - let socket = control_root.join("control.sock"); - let listener = UnixListener::bind(&socket).unwrap(); - fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap(); +fn active_http_prerequisite_stops_promptly_when_interrupted() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let address = listener.local_addr().unwrap(); + let terminate = Arc::new(AtomicBool::new(false)); + let release = Arc::new(AtomicBool::new(false)); + let signal = Arc::clone(&terminate); + let release_server = Arc::clone(&release); let server = thread::spawn(move || { - for _ in 0..2 { - let (mut stream, _) = listener.accept().unwrap(); - let mut request = [0u8; 7]; - stream.read_exact(&mut request).unwrap(); - assert_eq!(&request, b"status\n"); - stream.write_all(b"ready\n").unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut request = [0u8; 1]; + assert_eq!(stream.read(&mut request).unwrap(), 1); + signal.store(true, Ordering::Relaxed); + let deadline = Instant::now() + Duration::from_secs(2); + while !release_server.load(Ordering::Relaxed) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); } }); + let started = Instant::now(); - let report = start(StartArgs { - project: project.clone(), - clients_file: None, - casework_port: None, - mint_port: None, - database_port: None, - source_project: source_argument.to_vec(), - casework_bin: None, - mint_bin: None, - docker_bin: None, - bregctl_bin: Some(registry.executable.clone()), - }) - .unwrap(); - assert_eq!(report["status"], "ready"); + let result = http_cancellable( + "GET", + &format!("http://{address}/never-respond"), + None, + &[], + None, + &terminate, + ); + let elapsed = started.elapsed(); + release.store(true, Ordering::Relaxed); + server.join().unwrap(); + let refusal = format!("{:#}", result.unwrap_err()); + + assert!(refusal.contains("interrupted"), "{refusal}"); + assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); +} + +#[test] +fn service_http_readiness_stops_at_the_phase_deadline() { + let child = Command::new("/bin/sleep") + .arg("5") + .process_group(0) + .spawn() + .unwrap(); + let mut service = Service::from_guard(child, Vec::new()).unwrap(); + let terminate = AtomicBool::new(false); + let started = Instant::now(); + let deadline = started + Duration::from_millis(75); + let mut request_timeouts = Vec::new(); - registry.recreate(); let refusal = format!( "{:#}", - start(StartArgs { - project: project.clone(), - clients_file: None, - casework_port: None, - mint_port: None, - database_port: None, - source_project: source_argument.to_vec(), - casework_bin: None, - mint_bin: None, - docker_bin: None, - bregctl_bin: Some(registry.executable.clone()), + ready_with_probe(&service, &terminate, deadline, |timeout| { + request_timeouts.push(timeout); + // Model an HTTP request that consumes its entire allowance. The + // readiness phase must pass only its remaining budget each time. + thread::sleep(timeout); + false }) .unwrap_err() ); - server.join().unwrap(); + let elapsed = started.elapsed(); + let _ = service.stop_with_grace(Duration::from_millis(50), Duration::from_millis(10)); - assert!( - refusal.contains("active local development session"), - "{refusal}" - ); - assert!(refusal.contains("professional-register"), "{refusal}"); - assert!(refusal.contains("stop Casework"), "{refusal}"); - assert!(refusal.contains("start it again"), "{refusal}"); - assert_eq!( - read_state(&state.root()).unwrap().sources["professional-register"] - .binding - .as_ref() - .unwrap() - .audience, - "urn:breg:dev:fixture" - ); - remove_socket(&state.root()).unwrap(); + assert!(refusal.contains("readiness timed out"), "{refusal}"); + assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); + assert!(!request_timeouts.is_empty()); + assert!(request_timeouts[0] <= deadline.duration_since(started)); } #[test] -fn the_report_names_every_local_credential_without_a_secret() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let mut state = session(&project); - state.status = Status::Ready; - state.clients = vec![ReportedClient { - id: "requester".to_owned(), - profile: "requester".to_owned(), - role: CaseworkRole::Requester, - principal: config::principal("requester"), - }]; - state.directory_revision = 1; - state.directory_teams = 1; - let report = state.report(); - assert_eq!(report["caseworkUrl"], "http://127.0.0.1:8092"); +fn service_http_readiness_keeps_the_normal_request_timeout() { assert_eq!( - report["tokenEndpoint"], - "http://127.0.0.1:8093/oauth2/token" + readiness_http_timeout(HTTP_TIMEOUT + Duration::from_secs(5)), + HTTP_TIMEOUT ); - assert_eq!(report["audience"], state.audience()); - assert_eq!(report["directory"]["teams"], 1); - assert_eq!(report["directory"]["revision"], 1); - assert_eq!(report["clients"][0]["id"], "requester"); - assert_eq!(report["clients"][0]["role"], "requester"); assert_eq!( - report["clients"][0]["assertionKeyFile"], - json!(state.root().join("credentials/requester/assertion-key.jwk")) + readiness_http_timeout(Duration::from_millis(75)), + Duration::from_millis(75) ); - let text = report.to_string(); - assert!(!text.contains("token\":\""), "{text}"); - assert!(!text.contains("password"), "{text}"); } #[test] -fn stop_control_waits_for_the_complete_sequential_cleanup_budget() { - let child_shutdowns = Duration::from_secs(35 * 2); - // A timed-out Docker prerequisite gets its own graceful child shutdown - // before the supervisor can send the final response. - let database_shutdown = CHILD_DEADLINE * 3; - assert!(control_response_deadline("stop") >= child_shutdowns + database_shutdown); - assert!(control_response_deadline("status") < control_response_deadline("stop")); -} +fn prerequisite_logs_are_bounded_and_keep_the_latest_diagnostics() { + let root = tempfile::tempdir().unwrap(); + let logs = root.path().join("logs"); + private::directory(&logs).unwrap(); + let latest = format!("diagnostic-{}", MAX_PREREQUISITE_LOGS + 7); + for index in 0..MAX_PREREQUISITE_LOGS + 8 { + let mut log = log_file(root.path(), "probe").unwrap(); + writeln!(log, "diagnostic-{index}").unwrap(); + } -#[test] -fn a_completed_session_can_be_reclaimed_after_its_ports_are_reused() { - assert!(!service_ports_must_be_free(&Status::Stopped)); - assert!(!service_ports_must_be_free(&Status::Failed)); - assert!(service_ports_must_be_free(&Status::Starting)); - assert!(service_ports_must_be_free(&Status::Ready)); - assert!(service_ports_must_be_free(&Status::Stopping)); + let retained = fs::read_dir(&logs) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(retained.len(), MAX_PREREQUISITE_LOGS); + assert!(retained + .iter() + .any(|path| String::from_utf8_lossy(&fs::read(path).unwrap()).contains(&latest))); + for path in retained { + let metadata = fs::symlink_metadata(path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o077, 0); + assert_eq!(metadata.nlink(), 1); + } + + let unsafe_root = tempfile::tempdir().unwrap(); + let unsafe_logs = unsafe_root.path().join("logs"); + private::directory(&unsafe_logs).unwrap(); + let unsafe_path = unsafe_logs.join(format!("probe-{}.log", uuid::Uuid::new_v4())); + fs::write(&unsafe_path, b"must not rotate\n").unwrap(); + fs::set_permissions(&unsafe_path, fs::Permissions::from_mode(0o644)).unwrap(); + let refusal = format!("{:#}", log_file(unsafe_root.path(), "probe").unwrap_err()); + assert!(refusal.contains("owner-only"), "{refusal}"); + assert!(unsafe_path.exists()); } #[test] -fn source_backed_sessions_listen_only_on_the_casework_port() { +fn prerequisite_log_rotation_stays_in_the_opened_directory_after_a_path_swap() { let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let mut state = session(&project); + let logs = root.path().join("logs"); + private::directory(&logs).unwrap(); + for index in 0..MAX_PREREQUISITE_LOGS { + let mut log = log_file(root.path(), "probe").unwrap(); + writeln!(log, "diagnostic-{index}").unwrap(); + } + let redirected = tempfile::tempdir().unwrap(); + let names = fs::read_dir(&logs) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + for name in &names { + let path = redirected.path().join(name); + fs::write(&path, b"unrelated\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let opened_logs = root.path().join("opened-logs"); + + let mut log = log_file_with_rotation(root.path(), "probe", || { + fs::rename(&logs, &opened_logs).unwrap(); + std::os::unix::fs::symlink(redirected.path(), &logs).unwrap(); + }) + .unwrap(); + writeln!(log, "new diagnostic").unwrap(); + + assert!(names + .iter() + .all(|name| redirected.path().join(name).exists())); assert_eq!( - state.listening_ports(), - vec![state.casework_port, state.mint_port] - ); - state.sources.insert( - "registry".to_owned(), - SourceSession { - project: project.clone(), - binding: None, - }, + fs::read_dir(&opened_logs).unwrap().count(), + MAX_PREREQUISITE_LOGS ); - assert_eq!(state.listening_ports(), vec![state.casework_port]); } -struct DockerInventory { - _root: tempfile::TempDir, - executable: PathBuf, - container: PathBuf, - volume: PathBuf, - fail_create: PathBuf, -} - -impl DockerInventory { - fn new(state: &State) -> Self { - let root = tempfile::tempdir().unwrap(); - let executable = root.path().join("docker"); - let container = root.path().join("container-active"); - let volume = root.path().join("volume-active"); - let fail_create = root.path().join("fail-create"); - fs::write( - &executable, - br#"#!/bin/sh -set -eu -fixture=$(dirname "$0") -if [ "$1" = "ps" ]; then - if [ -f "$fixture/container-active" ]; then printf 'container\n'; fi -elif [ "$1" = "inspect" ]; then - cat "$fixture/container.json" -elif [ "$1" = "volume" ] && [ "$2" = "create" ]; then - touch "$fixture/volume-active" - cat "$fixture/volume-name" -elif [ "$1" = "volume" ] && [ "$2" = "ls" ]; then - if [ -f "$fixture/volume-active" ]; then cat "$fixture/volume-name"; fi -elif [ "$1" = "volume" ] && [ "$2" = "inspect" ]; then - cat "$fixture/volume.json" -elif [ "$1" = "create" ]; then - if [ -f "$fixture/fail-create" ]; then - printf 'injected container creation failure\n' >&2 - exit 42 - fi - touch "$fixture/container-active" - cat "$fixture/container-id" -else - printf 'unexpected fake Docker command: %s\n' "$*" >&2 - exit 43 -fi -"#, - ) - .unwrap(); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); - let id = "a".repeat(64); - fs::write(root.path().join("container-id"), format!("{id}\n")).unwrap(); - fs::write( - root.path().join("container.json"), - serde_json::to_vec(&json!([{ - "Id": id, - "Name": format!("/{}", state.container_name()), - "Config": { - "Labels": { (LABEL): state.owner.clone() }, - "Image": IMAGE, - }, - "State": { "Running": false }, - }])) - .unwrap(), - ) - .unwrap(); - fs::write( - root.path().join("volume-name"), - format!("{}\n", state.volume_name()), - ) - .unwrap(); - fs::write( - root.path().join("volume.json"), - serde_json::to_vec(&json!([{ - "Name": state.volume_name(), - "Labels": { (LABEL): state.owner.clone() }, - }])) - .unwrap(), - ) - .unwrap(); - Self { - _root: root, - executable, - container, - volume, - fail_create, - } - } - - fn deactivate(&self) { - for path in [&self.container, &self.volume] { - if path.exists() { - fs::remove_file(path).unwrap(); - } - } - } -} - -fn persisted_session(project: &Path) -> State { - let state = session(project); - private::directory(&project.join(".casework")).unwrap(); - private::directory(&state.root()).unwrap(); - private::directory(&state.root().join("logs")).unwrap(); - state.save().unwrap(); - state +#[test] +fn retained_service_journal_stays_bounded_and_keeps_latest_diagnostics() { + let root = tempfile::tempdir().unwrap(); + let logs = root.path().join("logs"); + private::directory(&logs).unwrap(); + let path = logs.join("casework.log"); + let old_marker = b"latest-before-restart\n"; + let mut oversized = vec![b'o'; MAX_BYTES as usize + 1024]; + oversized.extend_from_slice(old_marker); + private::create(&path, &oversized).unwrap(); + + let journal = RetainedJournal::open(&path).unwrap(); + drop(journal); + let compacted = fs::read(&path).unwrap(); + assert!(compacted.len() <= MAX_BYTES as usize); + assert!(compacted.ends_with(old_marker)); + + let new_marker = b"latest-during-service\n"; + let mut service_output = vec![b'n'; MAX_BYTES as usize + 1024]; + service_output.extend_from_slice(new_marker); + pump_retained( + std::io::Cursor::new(service_output), + RetainedJournal::open(&path).unwrap(), + ) + .unwrap(); + let after_service = fs::read(&path).unwrap(); + assert!(after_service.len() <= MAX_BYTES as usize); + assert!(after_service.ends_with(new_marker)); + + let restart_marker = b"latest-after-restart\n"; + pump_retained( + std::io::Cursor::new(restart_marker), + RetainedJournal::open(&path).unwrap(), + ) + .unwrap(); + let after_restart = fs::read(&path).unwrap(); + assert!(after_restart.len() <= MAX_BYTES as usize); + assert!(after_restart.ends_with(restart_marker)); + assert!(after_restart + .windows(new_marker.len()) + .any(|window| window == new_marker)); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o077, 0); + assert_eq!(metadata.nlink(), 1); } #[test] -fn config_change_keeps_the_owner_after_container_creation_fails() { - let workspace = tempfile::tempdir().unwrap(); - let project = standalone(workspace.path()); - let mut state = persisted_session(&project); - let docker = DockerInventory::new(&state); - fs::write(&docker.fail_create, b"").unwrap(); - - let refusal = format!( - "{:#}", - database(&docker.executable, &mut state, &AtomicBool::new(false)).unwrap_err() +fn supervisor_log_stays_bounded_and_resists_path_swaps_and_links() { + let root = tempfile::tempdir().unwrap(); + let logs = root.path().join("logs"); + private::directory(&logs).unwrap(); + let path = logs.join("supervisor.log"); + let old_marker = b"latest-previous-supervisor-failure\n"; + let mut oversized = vec![b'o'; MAX_BYTES as usize + 1024]; + oversized.extend_from_slice(old_marker); + private::create(&path, &oversized).unwrap(); + let new_error = anyhow::anyhow!( + "latest-current-supervisor-failure:{}", + "\u{1f980}".repeat(MAX_REFUSAL) ); - assert!(refusal.contains("create-database failed"), "{refusal}"); - let retained = read_state(&state.root()).unwrap(); - assert_eq!(retained.owner, state.owner); - assert!(retained.container_id.is_none()); - assert!(docker.volume.exists()); - assert!(!docker.container.exists()); + let bounded = bounded_supervisor_error(&new_error); - let refusal = format!( - "{:#}", - discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap_err() + let mut log = supervisor_log(root.path()).unwrap(); + writeln!(log, "{bounded}").unwrap(); + drop(log); + + let retained = fs::read(&path).unwrap(); + assert!(retained.len() <= MAX_BYTES as usize); + assert!(retained + .windows(old_marker.len()) + .any(|window| window == old_marker)); + assert!(String::from_utf8_lossy(&retained).contains("latest-current-supervisor-failure")); + assert_eq!(bounded.chars().count(), MAX_REFUSAL); + let maximum_width = + bounded_supervisor_error(&anyhow::anyhow!("{}", "\u{1f980}".repeat(MAX_REFUSAL + 1))); + // `main_entry` writes this string directly with `eprintln!("{error:#}")`: + // no prefix, and exactly one framing newline. + assert_eq!(maximum_width.len() + 1, MAX_SUPERVISOR_ERROR_BYTES as usize); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o077, 0); + assert_eq!(metadata.nlink(), 1); + + let swap_root = tempfile::tempdir().unwrap(); + let swap_logs = swap_root.path().join("logs"); + let moved_logs = swap_root.path().join("original-logs"); + let replacement_logs = swap_root.path().join("replacement-logs"); + private::directory(&swap_logs).unwrap(); + private::directory(&replacement_logs).unwrap(); + private::create( + &swap_logs.join("supervisor.log"), + b"original recent failure\n", + ) + .unwrap(); + private::create( + &replacement_logs.join("supervisor.log"), + b"replacement must stay unchanged\n", + ) + .unwrap(); + let mut log = supervisor_log_with_open(swap_root.path(), || { + fs::rename(&swap_logs, &moved_logs).unwrap(); + fs::rename(&replacement_logs, &swap_logs).unwrap(); + }) + .unwrap(); + writeln!(log, "current failure").unwrap(); + drop(log); + assert_eq!( + fs::read(swap_logs.join("supervisor.log")).unwrap(), + b"replacement must stay unchanged\n" ); - assert!( - refusal.contains("still owns database resources"), - "{refusal}" + assert_eq!( + fs::read(moved_logs.join("supervisor.log")).unwrap(), + b"original recent failure\ncurrent failure\n" ); - assert_eq!(read_state(&state.root()).unwrap().owner, retained.owner); - docker.deactivate(); - discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap(); - assert!(!state.root().exists()); + let hardlink_root = tempfile::tempdir().unwrap(); + let hardlink_logs = hardlink_root.path().join("logs"); + private::directory(&hardlink_logs).unwrap(); + let target = hardlink_logs.join("target.log"); + private::create(&target, b"preserve me").unwrap(); + let linked = hardlink_logs.join("supervisor.log"); + fs::hard_link(&target, &linked).unwrap(); + let refusal = format!("{:#}", supervisor_log(hardlink_root.path()).unwrap_err()); + assert!(refusal.contains("single-link"), "{refusal}"); + assert_eq!(fs::read(&target).unwrap(), b"preserve me"); + + let symlink_root = tempfile::tempdir().unwrap(); + let symlink_logs = symlink_root.path().join("logs"); + private::directory(&symlink_logs).unwrap(); + let target = symlink_logs.join("target.log"); + private::create(&target, b"preserve me too").unwrap(); + let linked = symlink_logs.join("supervisor.log"); + std::os::unix::fs::symlink(&target, &linked).unwrap(); + let refusal = format!("{:#}", supervisor_log(symlink_root.path()).unwrap_err()); + assert!(refusal.contains("supervisor journal"), "{refusal}"); + assert_eq!(fs::read(&target).unwrap(), b"preserve me too"); } #[test] -fn config_change_keeps_the_owner_after_created_container_cannot_be_saved() { - let workspace = tempfile::tempdir().unwrap(); - let project = standalone(workspace.path()); - let mut state = persisted_session(&project); - let docker = DockerInventory::new(&state); - fs::set_permissions(state.root(), fs::Permissions::from_mode(0o500)).unwrap(); - - let result = database(&docker.executable, &mut state, &AtomicBool::new(false)); - fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); - let refusal = format!("{:#}", result.unwrap_err()); - assert!(refusal.contains("cannot be created"), "{refusal}"); - let retained = read_state(&state.root()).unwrap(); - assert_eq!(retained.owner, state.owner); - assert!(retained.container_id.is_none()); - assert!(docker.volume.exists()); - assert!(docker.container.exists()); +fn invalid_service_journal_is_refused_before_the_child_starts() { + let root = tempfile::tempdir().unwrap(); + let logs = root.path().join("logs"); + private::directory(&logs).unwrap(); + let journal = logs.join("casework.log"); + fs::write(&journal, b"").unwrap(); + fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap(); + let marker = root.path().join("child-started"); let refusal = format!( "{:#}", - discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap_err() - ); - assert!( - refusal.contains("still owns database resources"), - "{refusal}" + service( + Path::new("/usr/bin/touch"), + &[], + &marker, + &[], + root.path(), + "casework", + ) + .err() + .expect("unsafe journal must be refused") ); - assert_eq!(read_state(&state.root()).unwrap().owner, retained.owner); + thread::sleep(Duration::from_millis(100)); - docker.deactivate(); - discard_changed_state(&state.root(), &retained, Some(&docker.executable)).unwrap(); - assert!(!state.root().exists()); + assert!(refusal.contains("owner-only"), "{refusal}"); + assert!(!marker.exists()); } #[test] -fn volume_removal_requires_the_retained_owner_label() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let state = session(&project); - let mut wrong_labels = serde_json::Map::new(); - wrong_labels.insert(LABEL.to_owned(), Value::String("another-owner".to_owned())); - let unrelated = json!({ - "Name": state.volume_name(), - "Labels": Value::Object(wrong_labels), - }); - let mut removed = false; - - let refusal = format!( - "{:#}", - remove_verified_volume(&state, Some(unrelated), None, |_| { - removed = true; - Ok(()) - }) - .unwrap_err() - ); - - assert!(refusal.contains("volume ownership differs"), "{refusal}"); - assert!(!removed); - - let mut owned_labels = serde_json::Map::new(); - owned_labels.insert(LABEL.to_owned(), Value::String(state.owner.clone())); - let owned = json!({ - "Name": state.volume_name(), - "Labels": Value::Object(owned_labels), - }); - remove_verified_volume(&state, Some(owned), None, |name| { - assert_eq!(name, state.volume_name()); - removed = true; - Ok(()) - }) +fn service_guard_process_helper() { + let Some(encoded) = std::env::var_os("CASEWORKCTL_TEST_SERVICE_GUARD_ARGV") else { + return; + }; + let mut arguments: Vec = serde_json::from_str(&encoded.to_string_lossy()).unwrap(); + let binary = arguments.remove(0); + let interruption = StartInterruption::install().unwrap(); + let mut command = Command::new(binary); + command.args(arguments).stdin(Stdio::null()); + let forced = guard_service_command( + command, + std::io::stdin(), + Arc::clone(&interruption.requested), + Duration::from_millis(500), + Duration::from_millis(500), + ) .unwrap(); - assert!(removed); -} - -fn legacy_database_container(state: &State, volume_name: &str, destination: &str) -> Value { - let mut labels = serde_json::Map::new(); - labels.insert(LABEL.to_owned(), Value::String(state.owner.clone())); - json!({ - "Id": state.container_id.as_ref().unwrap(), - "Name": format!("/{}", state.container_name()), - "Config": { - "Labels": Value::Object(labels), - "Image": IMAGE, - }, - "Mounts": [{ - "Type": "volume", - "Name": volume_name, - "Destination": destination, - }], - }) + if forced { + std::process::exit(i32::from(SERVICE_GUARD_FORCED_EXIT)); + } } #[test] -fn legacy_unlabeled_volume_requires_the_exact_retained_container_and_mount() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let mut state = session(&project); - state.container_id = Some("retained-container-id".to_owned()); - let volume = json!({ - "Name": state.volume_name(), - "Labels": null, - }); - let container = - legacy_database_container(&state, &state.volume_name(), "/var/lib/postgresql/data"); - let mut removed = false; - - remove_verified_volume(&state, Some(volume.clone()), Some(&container), |name| { - assert_eq!(name, state.volume_name()); - removed = true; - Ok(()) - }) +fn service_guard_supervisor_helper() { + let Some(root) = std::env::var_os("CASEWORKCTL_TEST_GUARD_ROOT").map(PathBuf::from) else { + return; + }; + let binary = PathBuf::from(std::env::var_os("CASEWORKCTL_TEST_GUARD_BINARY").unwrap()); + private::directory(&root.join("logs")).unwrap(); + let _service = service( + &binary, + &[], + &root.join("service.pid"), + &[], + &root, + "guarded", + ) .unwrap(); - assert!(removed); - - for (container, expected) in [ - (None, "retained container is absent"), - ( - Some(legacy_database_container( - &state, - "different-volume", - "/var/lib/postgresql/data", - )), - "is not mounted", - ), - ( - Some(legacy_database_container( - &state, - &state.volume_name(), - "/different-destination", - )), - "is not mounted", - ), - ] { - removed = false; - let refusal = format!( - "{:#}", - remove_verified_volume(&state, Some(volume.clone()), container.as_ref(), |_| { - removed = true; - Ok(()) - }) - .unwrap_err() - ); - assert!(refusal.contains(expected), "{refusal}"); - assert!(!removed); + loop { + thread::sleep(Duration::from_secs(1)); } +} - let mut wrong_container = container.clone(); - wrong_container["Id"] = Value::String("different-container-id".to_owned()); - removed = false; - let refusal = format!( - "{:#}", - remove_verified_volume(&state, Some(volume), Some(&wrong_container), |_| { - removed = true; - Ok(()) - }) - .unwrap_err() +#[test] +fn nonzero_outer_guard_helper() { + let Some(service_pid_file) = + std::env::var_os("CASEWORKCTL_TEST_NONZERO_GUARD_PID").map(PathBuf::from) + else { + return; + }; + let service_binary = std::env::var_os("CASEWORKCTL_TEST_NONZERO_GUARD_SERVICE").unwrap(); + let _child = Command::new(service_binary) + .arg(&service_pid_file) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + assert!( + file_has_bytes(&service_pid_file), + "service did not become ready" ); - assert!(refusal.contains("container ownership differs"), "{refusal}"); - assert!(!removed); + std::process::exit(23); } #[test] -fn foreground_interruption_terminates_and_reaps_its_owned_supervisor() { - let workspace = tempfile::tempdir().unwrap(); - let project = standalone(workspace.path()); - let mut state = session(&project); - state.status = Status::Starting; - fs::create_dir_all(state.root()).unwrap(); - fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); - state.save().unwrap(); - let mut supervisor = Command::new("/bin/sh") - .args(["-c", "while :; do :; done"]) +fn guarded_service_stops_after_its_supervisor_is_killed() { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("service.sh"); + fs::write( + &binary, + b"#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut supervisor = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "dev::tests::service_guard_supervisor_helper", + "--nocapture", + ]) + .env("CASEWORKCTL_TEST_GUARD_ROOT", root.path()) + .env("CASEWORKCTL_TEST_GUARD_BINARY", &binary) .stdin(Stdio::null()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .spawn() .unwrap(); - let interrupted = AtomicBool::new(true); + let service_pid_file = root.path().join("service.pid"); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + if !file_has_bytes(&service_pid_file) { + supervisor.kill().unwrap(); + supervisor.wait().unwrap(); + panic!("guarded service did not start"); + } + let service_pid = fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(); + let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); + let started = Instant::now(); - let refusal = format!( - "{:#}", - wait_for_start(&state.root(), &mut supervisor, &interrupted).unwrap_err() - ); + // SIGKILL skips every supervisor destructor. The kernel still closes the + // supervisor's liveness writer, which must stop the exact guarded child. + supervisor.kill().unwrap(); + supervisor.wait().unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while rustix::process::test_kill_process(service_pid).is_ok() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let stopped = rustix::process::test_kill_process(service_pid).is_err(); + if !stopped { + rustix::process::kill_process(service_pid, rustix::process::Signal::KILL).unwrap(); + } - assert!(refusal.contains("local start interrupted"), "{refusal}"); - assert!(supervisor.try_wait().unwrap().is_some()); - let retained = read_state(&state.root()).unwrap(); - assert!(matches!(retained.status, Status::Failed)); - assert!(retained - .failure - .is_some_and(|failure| failure.contains("interrupted"))); + assert!(stopped, "guarded service survived supervisor death"); + assert!(started.elapsed() < Duration::from_secs(5)); } #[test] -fn failed_start_waits_for_the_supervisor_lock_to_be_released() { - let workspace = tempfile::tempdir().unwrap(); - let project = standalone(workspace.path()); - let mut state = session(&project); - state.status = Status::Failed; - state.failure = Some("injected supervisor failure".to_owned()); - fs::create_dir_all(state.root()).unwrap(); - fs::set_permissions(project.join(".casework"), fs::Permissions::from_mode(0o700)).unwrap(); - fs::set_permissions(state.root(), fs::Permissions::from_mode(0o700)).unwrap(); - state.save().unwrap(); - let lock = private::lock(&state.root().join("supervisor.lock")).unwrap(); - let release = thread::spawn(move || { - thread::sleep(Duration::from_millis(50)); - drop(lock); - }); - let mut supervisor = Command::new("/bin/sleep").arg("0.2").spawn().unwrap(); - let interrupted = AtomicBool::new(false); - let started = Instant::now(); - - let refusal = format!( - "{:#}", - wait_for_start(&state.root(), &mut supervisor, &interrupted).unwrap_err() - ); - let elapsed = started.elapsed(); - release.join().unwrap(); +fn service_guard_owns_a_stubborn_child_during_startup_interruption() { + let root = tempfile::tempdir().unwrap(); + let binary = root.path().join("stubborn.sh"); + let service_pid_file = root.path().join("service.pid"); + fs::write( + &binary, + b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let (reader, writer) = UnixStream::pair().unwrap(); + let terminate = Arc::new(AtomicBool::new(false)); + let request = Arc::clone(&terminate); + let marker = service_pid_file.clone(); + let requester = thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(5); + while !marker.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + let ready = marker.exists(); + if ready { + request.store(true, Ordering::Relaxed); + } + // EOF is also a cleanup request if readiness failed, so the assertion + // below cannot strand whatever the guard managed to spawn. + drop(writer); + ready + }); + let mut command = Command::new(&binary); + command.arg(&service_pid_file).stdin(Stdio::null()); - assert!(refusal.contains("injected supervisor failure"), "{refusal}"); - assert!(supervisor.try_wait().unwrap().is_some()); + let forced = guard_service_command( + command, + reader, + terminate, + Duration::from_millis(50), + Duration::from_millis(50), + ) + .unwrap(); + let ready = requester.join().unwrap(); assert!( - elapsed >= Duration::from_millis(150), - "elapsed: {elapsed:?}" + ready, + "stubborn service did not reach its startup handshake" ); - assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); + let service_pid = fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(); + let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); + + assert!(forced); + assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); } #[test] -fn migration_failures_use_the_bounded_native_diagnostic_stream() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let refusal = format!( - "{:#}", - command( - Command::new("/bin/sh").args([ - "-c", - "printf 'casework: schema upgrade refused safely\\n' >&2; exit 1", - ]), - root.path(), - "migrate", - None, - ) - .unwrap_err() - ); - assert!( - refusal.contains("schema upgrade refused safely"), - "{refusal}" - ); - assert!(refusal.contains("owner-only diagnostics"), "{refusal}"); +fn service_guard_does_not_force_kill_after_a_fast_term_exit() { + let (reader, writer) = UnixStream::pair().unwrap(); + drop(writer); + let mut command = Command::new("/bin/sleep"); + command.arg("5").stdin(Stdio::null()); + + let forced = guard_service_command( + command, + reader, + Arc::new(AtomicBool::new(false)), + Duration::from_millis(100), + Duration::from_millis(100), + ) + .unwrap(); + + assert!(!forced); } #[test] -fn database_readiness_commands_stop_at_the_aggregate_deadline() { +fn established_service_keeps_its_graceful_shutdown_window() { let root = tempfile::tempdir().unwrap(); private::directory(&root.path().join("logs")).unwrap(); - let started = Instant::now(); - let deadline = started + Duration::from_millis(75); - let terminate = AtomicBool::new(false); + let binary = root.path().join("service.sh"); + let graceful = root.path().join("graceful"); + fs::write( + &binary, + b"#!/bin/sh\ntrap 'sleep 0.2; printf graceful > \"$2\"; exit 0' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let service_pid_file = root.path().join("service.pid"); + let mut service = service( + &binary, + &[], + &service_pid_file, + &[graceful.to_str().unwrap()], + root.path(), + "guarded", + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + if !file_has_bytes(&service_pid_file) { + let _ = service.stop(); + panic!("service did not reach its startup handshake"); + } - let refusal = format!( - "{:#}", - command_before_cancellable( - Command::new("/bin/sh").args(["-c", "while :; do :; done"]), - root.path(), - "database-readiness", - None, - deadline, - &terminate, - ) - .unwrap_err() - ); + let started = Instant::now(); + service.stop().unwrap(); - assert!(refusal.contains("timed out"), "{refusal}"); + assert!(graceful.exists(), "guardian truncated graceful shutdown"); + assert!(started.elapsed() >= Duration::from_millis(150)); assert!(started.elapsed() < Duration::from_secs(1)); } #[test] -fn interrupted_native_prerequisite_is_killed_and_reaped() { +fn established_stubborn_service_reports_forced_shutdown() { let root = tempfile::tempdir().unwrap(); private::directory(&root.path().join("logs")).unwrap(); - let marker = root.path().join("prerequisite.pid"); - let terminate = Arc::new(AtomicBool::new(false)); - let signal = Arc::clone(&terminate); - let marker_for_signal = marker.clone(); - let interrupter = thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(1); - while !marker_for_signal.exists() { - assert!(Instant::now() < deadline, "prerequisite did not start"); - thread::sleep(Duration::from_millis(5)); - } - signal.store(true, Ordering::Relaxed); - }); - let started = Instant::now(); + let binary = root.path().join("stubborn.sh"); + let service_pid_file = root.path().join("service.pid"); + fs::write( + &binary, + b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut service = + service(&binary, &[], &service_pid_file, &[], root.path(), "guarded").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + if !file_has_bytes(&service_pid_file) { + let _ = service.stop(); + panic!("stubborn service did not reach its startup handshake"); + } + let service_pid = fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(); + let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - let refusal = format!( - "{:#}", - command_cancellable( - Command::new("/bin/sh") - .arg("-c") - .arg("printf '%s' \"$$\" > \"$1\"; while :; do :; done") - .arg("prerequisite") - .arg(&marker), - root.path(), - "interruptible-prerequisite", - None, - &terminate, - ) - .unwrap_err() - ); - interrupter.join().unwrap(); - let pid = fs::read_to_string(&marker).unwrap().parse::().unwrap(); - let pid = rustix::process::Pid::from_raw(pid).unwrap(); + let refusal = format!("{:#}", service.stop().unwrap_err()); - assert!(refusal.contains("interrupted"), "{refusal}"); - assert!(started.elapsed() < Duration::from_secs(1)); - assert!(rustix::process::test_kill_process(pid).is_err()); + assert!(refusal.contains("required forced shutdown"), "{refusal}"); + assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); } #[test] -fn native_pump_setup_failures_reap_the_child_and_join_started_pumps() { +fn killed_guard_leaves_the_supervisor_to_clean_its_exact_service_group() { let root = tempfile::tempdir().unwrap(); private::directory(&root.path().join("logs")).unwrap(); - for fail_on in [1, 2] { - let child = Command::new("/bin/sleep") - .arg("5") - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); - let log = log_file(root.path(), "pump-setup").unwrap(); - let joined = Arc::new(AtomicBool::new(false)); - let mut calls = 0; - let started = Instant::now(); + let binary = root.path().join("stubborn.sh"); + let service_pid_file = root.path().join("service.pid"); + fs::write( + &binary, + b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut service = + service(&binary, &[], &service_pid_file, &[], root.path(), "guarded").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + assert!( + file_has_bytes(&service_pid_file), + "guarded service did not start" + ); + let service_pid = rustix::process::Pid::from_raw( + fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(), + ) + .unwrap(); + assert_eq!( + rustix::process::getpgid(Some(service_pid)).unwrap(), + service.guard_pgid, + "the actual service must inherit the guard's pinned group" + ); - let refusal = format!( - "{:#}", - output_from_child_with_pump_spawner( - child, - NativeRun { - root: root.path(), - name: "pump-setup", - log, - input: None, - aggregate_deadline: None, - terminate: None, - }, - |_stream, task| { - calls += 1; - if calls == fail_on { - return Err(std::io::Error::other("injected pump spawn failure")); - } - let joined = Arc::clone(&joined); - thread::Builder::new().spawn(move || { - let result = task(); - joined.store(true, Ordering::Relaxed); - result - }) - }, - ) - .err() - .expect("selected pump spawn must fail") - ); - - let reader = if fail_on == 1 { "output" } else { "diagnostic" }; - assert!(refusal.contains(reader), "{refusal}"); - assert!(started.elapsed() < Duration::from_secs(1)); - assert_eq!(joined.load(Ordering::Relaxed), fail_on == 2); - assert!(rustix::process::test_kill_process(pid).is_err()); + rustix::process::kill_process(service.guard_pid, rustix::process::Signal::KILL).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while service.guard_exit().unwrap().is_none() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); } + assert!( + service.guard_exit().unwrap().is_some(), + "killed guard did not become waitable" + ); + assert!( + rustix::process::test_kill_process(service_pid).is_ok(), + "the regression requires a service left alive by its killed guard" + ); + + let refusal = format!( + "{:#}", + service + .stop_with_grace(Duration::from_millis(100), Duration::from_millis(25)) + .unwrap_err() + ); + + assert!(refusal.contains("guard exited abnormally"), "{refusal}"); + assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); } #[test] -fn failed_native_stdin_write_reaps_the_child_and_joins_pumps() { +fn nonzero_guard_exit_is_detected_without_waiting_for_pump_eof() { let root = tempfile::tempdir().unwrap(); private::directory(&root.path().join("logs")).unwrap(); - let child = Command::new("/bin/sh") - .args(["-c", "exec 0<&-; while :; do :; done"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); - let log = log_file(root.path(), "closed-stdin").unwrap(); - let input = vec![b'x'; MAX_BYTES as usize]; - let stdout_joined = Arc::new(AtomicBool::new(false)); - let stderr_joined = Arc::new(AtomicBool::new(false)); + let service_pid_file = root.path().join("service.pid"); + let service_binary = root.path().join("service.sh"); + fs::write( + &service_binary, + b"#!/bin/sh\ntrap '' HUP TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&service_binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut guard = Command::new(std::env::current_exe().unwrap()); + guard + .args([ + "--exact", + "dev::tests::nonzero_outer_guard_helper", + "--nocapture", + ]) + .env("CASEWORKCTL_TEST_NONZERO_GUARD_PID", &service_pid_file) + .env("CASEWORKCTL_TEST_NONZERO_GUARD_SERVICE", &service_binary); + let mut service = service_with_guard_command(guard, root.path(), "guarded").unwrap(); + // The helper itself permits five seconds for the service PID file. Give + // the parent that complete startup budget plus scheduling margin when the + // full test suite is running concurrently. + let deadline = Instant::now() + Duration::from_secs(6); + while (!file_has_bytes(&service_pid_file) || service.guard_exit().unwrap().is_none()) + && Instant::now() < deadline + { + thread::sleep(Duration::from_millis(5)); + } + assert!( + file_has_bytes(&service_pid_file), + "guard did not create its service" + ); + assert!( + service.guard_exit().unwrap().is_some(), + "nonzero guard did not become waitable" + ); + let service_pid = rustix::process::Pid::from_raw( + fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(), + ) + .unwrap(); + assert!(rustix::process::test_kill_process(service_pid).is_ok()); let started = Instant::now(); let refusal = format!( "{:#}", - output_from_child_with_pump_spawner( - child, - NativeRun { - root: root.path(), - name: "closed-stdin", - log, - input: Some(Input { - bytes: &input, - secret: None, - }), - aggregate_deadline: None, - terminate: None, - }, - |stream, task| { - let joined = if stream == "stdout" { - Arc::clone(&stdout_joined) - } else { - Arc::clone(&stderr_joined) - }; - thread::Builder::new().spawn(move || { - let result = task(); - joined.store(true, Ordering::Relaxed); - result - }) - }, - ) - .err() - .expect("closed stdin must refuse the input") + service + .stop_with_grace(Duration::from_millis(50), Duration::from_millis(10)) + .unwrap_err() ); - assert!( - refusal.contains("write native prerequisite input"), - "{refusal}" - ); + assert!(refusal.contains("guard exited abnormally"), "{refusal}"); assert!(started.elapsed() < Duration::from_secs(1)); - assert!(stdout_joined.load(Ordering::Relaxed)); - assert!(stderr_joined.load(Ordering::Relaxed)); - assert!(rustix::process::test_kill_process(pid).is_err()); + assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); } #[test] -fn active_http_prerequisite_stops_promptly_when_interrupted() { - let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); - let address = listener.local_addr().unwrap(); - let terminate = Arc::new(AtomicBool::new(false)); - let release = Arc::new(AtomicBool::new(false)); - let signal = Arc::clone(&terminate); - let release_server = Arc::clone(&release); - let server = thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - stream - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); - let mut request = [0u8; 1]; - assert_eq!(stream.read(&mut request).unwrap(), 1); - signal.store(true, Ordering::Relaxed); - let deadline = Instant::now() + Duration::from_secs(2); - while !release_server.load(Ordering::Relaxed) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - }); +fn live_guard_timeout_kills_the_pinned_group_before_reaping() { + let root = tempfile::tempdir().unwrap(); + private::directory(&root.path().join("logs")).unwrap(); + let guard_binary = root.path().join("guard.sh"); + let service_pid_file = root.path().join("service.pid"); + // Publish the descendant PID from the guard that created it. This proves + // the group member exists without depending on when that child is scheduled. + fs::write( + &guard_binary, + b"#!/bin/sh\ntrap '' TERM\n/bin/sleep 60 &\nprintf '%s' \"$!\" > \"$1\"\nexec /bin/sleep 60\n", + ) + .unwrap(); + fs::set_permissions(&guard_binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut guard = Command::new(&guard_binary); + guard.arg(&service_pid_file); + let mut service = service_with_guard_command(guard, root.path(), "guarded").unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + if !file_has_bytes(&service_pid_file) { + let _ = service.stop_with_grace(Duration::from_millis(75), Duration::from_millis(10)); + panic!("guarded service did not start"); + } + let service_pid = rustix::process::Pid::from_raw( + fs::read_to_string(&service_pid_file) + .unwrap() + .parse::() + .unwrap(), + ) + .unwrap(); + assert!(rustix::process::test_kill_process(service_pid).is_ok()); + assert_eq!( + rustix::process::getpgid(Some(service_pid)).unwrap(), + service.guard_pgid, + "the descendant must join the guard's pinned group" + ); let started = Instant::now(); - let result = http_cancellable( - "GET", - &format!("http://{address}/never-respond"), - None, - &[], - None, - &terminate, + let refusal = format!( + "{:#}", + service + .stop_with_grace(Duration::from_millis(75), Duration::from_millis(10)) + .unwrap_err() ); - let elapsed = started.elapsed(); - release.store(true, Ordering::Relaxed); - server.join().unwrap(); - let refusal = format!("{:#}", result.unwrap_err()); - assert!(refusal.contains("interrupted"), "{refusal}"); - assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); + assert!(refusal.contains("required forced shutdown"), "{refusal}"); + assert!(started.elapsed() >= Duration::from_millis(70)); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); } #[test] -fn service_http_readiness_stops_at_the_phase_deadline() { - let child = Command::new("/bin/sleep") +fn post_kill_wait_is_bounded_when_a_guard_does_not_become_waitable() { + let mut guard = Command::new("/bin/sleep") .arg("5") .process_group(0) .spawn() .unwrap(); - let mut service = Service::from_guard(child, Vec::new()).unwrap(); - let terminate = AtomicBool::new(false); + let guard_pid = rustix::process::Pid::from_raw(guard.id() as i32).unwrap(); let started = Instant::now(); - let deadline = started + Duration::from_millis(75); - let mut request_timeouts = Vec::new(); + // Model a kernel reporting successful group KILL without making the guard + // waitable. Cleanup must return at its own bound instead of entering a + // blocking Child::wait. let refusal = format!( "{:#}", - ready_with_probe(&service, &terminate, deadline, |timeout| { - request_timeouts.push(timeout); - // Model an HTTP request that consumes its entire allowance. The - // readiness phase must pass only its remaining budget each time. - thread::sleep(timeout); - false - }) + kill_guard_group_and_reap_with(&mut guard, guard_pid, Duration::from_millis(40), |_pgid| { + Ok(()) + },) .unwrap_err() ); - let elapsed = started.elapsed(); - let _ = service.stop_with_grace(Duration::from_millis(50), Duration::from_millis(10)); - assert!(refusal.contains("readiness timed out"), "{refusal}"); - assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); - assert!(!request_timeouts.is_empty()); - assert!(request_timeouts[0] <= deadline.duration_since(started)); + assert!(refusal.contains("bounded cleanup wait"), "{refusal}"); + assert!(started.elapsed() >= Duration::from_millis(35)); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(guard_exit(guard_pid).unwrap().is_none()); + rustix::process::kill_process(guard_pid, rustix::process::Signal::KILL).unwrap(); + guard.wait().unwrap(); } #[test] -fn service_http_readiness_keeps_the_normal_request_timeout() { - assert_eq!( - readiness_http_timeout(HTTP_TIMEOUT + Duration::from_secs(5)), - HTTP_TIMEOUT - ); - assert_eq!( - readiness_http_timeout(Duration::from_millis(75)), - Duration::from_millis(75) +fn failed_group_kill_never_enters_a_blocking_guard_wait() { + let mut guard = Command::new("/bin/sleep") + .arg("5") + .process_group(0) + .spawn() + .unwrap(); + let guard_pid = rustix::process::Pid::from_raw(guard.id() as i32).unwrap(); + let started = Instant::now(); + + let refusal = format!( + "{:#}", + kill_guard_group_and_reap_with(&mut guard, guard_pid, Duration::from_secs(1), |_pgid| Err( + anyhow::anyhow!("injected group KILL failure") + ),) + .unwrap_err() ); + + assert!(refusal.contains("cannot KILL"), "{refusal}"); + assert!(refusal.contains("injected group KILL failure"), "{refusal}"); + assert!(started.elapsed() < Duration::from_millis(100)); + assert!(guard_exit(guard_pid).unwrap().is_none()); + rustix::process::kill_process(guard_pid, rustix::process::Signal::KILL).unwrap(); + guard.wait().unwrap(); } #[test] -fn prerequisite_logs_are_bounded_and_keep_the_latest_diagnostics() { +fn service_pump_setup_failures_reap_the_child_and_join_started_pumps() { let root = tempfile::tempdir().unwrap(); let logs = root.path().join("logs"); private::directory(&logs).unwrap(); - let latest = format!("diagnostic-{}", MAX_PREREQUISITE_LOGS + 7); - for index in 0..MAX_PREREQUISITE_LOGS + 8 { - let mut log = log_file(root.path(), "probe").unwrap(); - writeln!(log, "diagnostic-{index}").unwrap(); - } + for fail_on in [1, 2] { + let journal = RetainedJournal::open(&logs.join("casework.log")).unwrap(); + let child = Command::new("/bin/sh") + .args(["-c", "read ignored || exit 0"]) + .process_group(0) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); + let joined = Arc::new(AtomicBool::new(false)); + let mut calls = 0; + let started = Instant::now(); - let retained = fs::read_dir(&logs) - .unwrap() - .map(|entry| entry.unwrap().path()) - .collect::>(); - assert_eq!(retained.len(), MAX_PREREQUISITE_LOGS); - assert!(retained - .iter() - .any(|path| String::from_utf8_lossy(&fs::read(path).unwrap()).contains(&latest))); - for path in retained { - let metadata = fs::symlink_metadata(path).unwrap(); - assert_eq!(metadata.permissions().mode() & 0o077, 0); - assert_eq!(metadata.nlink(), 1); - } + let refusal = format!( + "{:#}", + service_with_pump_spawner(child, journal, |_stream, task| { + calls += 1; + if calls == fail_on { + return Err(std::io::Error::other("injected pump spawn failure")); + } + let joined = Arc::clone(&joined); + thread::Builder::new().spawn(move || { + let result = task(); + joined.store(true, Ordering::Relaxed); + result + }) + }) + .err() + .expect("selected pump spawn must fail") + ); - let unsafe_root = tempfile::tempdir().unwrap(); - let unsafe_logs = unsafe_root.path().join("logs"); - private::directory(&unsafe_logs).unwrap(); - let unsafe_path = unsafe_logs.join(format!("probe-{}.log", uuid::Uuid::new_v4())); - fs::write(&unsafe_path, b"must not rotate\n").unwrap(); - fs::set_permissions(&unsafe_path, fs::Permissions::from_mode(0o644)).unwrap(); - let refusal = format!("{:#}", log_file(unsafe_root.path(), "probe").unwrap_err()); - assert!(refusal.contains("owner-only"), "{refusal}"); - assert!(unsafe_path.exists()); + let reader = if fail_on == 1 { "output" } else { "diagnostic" }; + assert!(refusal.contains(reader), "{refusal}"); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(joined.load(Ordering::Relaxed), fail_on == 2); + assert!(rustix::process::test_kill_process(pid).is_err()); + } } #[test] -fn prerequisite_log_rotation_stays_in_the_opened_directory_after_a_path_swap() { +fn guardian_pump_setup_failure_reaps_a_stubborn_owned_service() { let root = tempfile::tempdir().unwrap(); let logs = root.path().join("logs"); private::directory(&logs).unwrap(); - for index in 0..MAX_PREREQUISITE_LOGS { - let mut log = log_file(root.path(), "probe").unwrap(); - writeln!(log, "diagnostic-{index}").unwrap(); - } - let redirected = tempfile::tempdir().unwrap(); - let names = fs::read_dir(&logs) + let binary = root.path().join("stubborn.sh"); + let service_pid_file = root.path().join("service.pid"); + fs::write( + &binary, + b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + let mut guardian = + service_guard_command(&binary, &[service_pid_file.as_os_str().to_owned()]).unwrap(); + let child = guardian + .process_group(0) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let journal = RetainedJournal::open(&logs.join("casework.log")).unwrap(); + let started = Instant::now(); + let mut ready = false; + + let refusal = format!( + "{:#}", + service_with_pump_spawner(child, journal, |_stream, _task| { + let deadline = Instant::now() + Duration::from_secs(5); + while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + ready = file_has_bytes(&service_pid_file); + Err(std::io::Error::other("injected pump spawn failure")) + }) + .err() + .expect("injected guardian pump spawn must fail") + ); + assert!( + ready, + "stubborn service did not reach its startup handshake" + ); + let service_pid = fs::read_to_string(&service_pid_file) .unwrap() - .map(|entry| entry.unwrap().file_name()) - .collect::>(); - for name in &names { - let path = redirected.path().join(name); - fs::write(&path, b"unrelated\n").unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); - } - let opened_logs = root.path().join("opened-logs"); + .parse::() + .unwrap(); + let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - let mut log = log_file_with_rotation(root.path(), "probe", || { - fs::rename(&logs, &opened_logs).unwrap(); - std::os::unix::fs::symlink(redirected.path(), &logs).unwrap(); + assert!(refusal.contains("output reader"), "{refusal}"); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(rustix::process::test_kill_process(service_pid).is_err()); +} + +#[test] +fn seeding_administrator_token_is_issued_after_every_other_client() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let mut state = session(&project); + let clients = Clients { + version: 1, + clients: (0..32) + .map(|index| config::Client { + id: if index == 0 { + "administrator".to_owned() + } else { + format!("client-{index}") + }, + access_profile: format!("profile-{index}"), + scopes: vec!["casework:test".to_owned()], + claims: BTreeMap::new(), + }) + .collect(), + directory: Vec::new(), + integrations: None, + }; + state.clients = clients + .clients + .iter() + .enumerate() + .map(|(index, client)| ReportedClient { + id: client.id.clone(), + profile: client.access_profile.clone(), + role: if index == 0 { + CaseworkRole::Administrator + } else { + CaseworkRole::Requester + }, + principal: config::principal(&client.id), + }) + .collect(); + let mut issued = Vec::new(); + let terminate = AtomicBool::new(false); + + issue_tokens(&state, &clients, &terminate, |id| { + issued.push(id.to_owned()); + Ok(()) }) .unwrap(); - writeln!(log, "new diagnostic").unwrap(); - assert!(names - .iter() - .all(|name| redirected.path().join(name).exists())); + assert_eq!(issued.len(), clients.clients.len()); + assert_eq!(issued.last().map(String::as_str), Some("administrator")); assert_eq!( - fs::read_dir(&opened_logs).unwrap().count(), - MAX_PREREQUISITE_LOGS + issued.into_iter().collect::>(), + clients + .clients + .iter() + .map(|client| client.id.clone()) + .collect() ); } #[test] -fn retained_service_journal_stays_bounded_and_keeps_latest_diagnostics() { +fn token_issuance_stops_between_clients_when_interrupted() { let root = tempfile::tempdir().unwrap(); - let logs = root.path().join("logs"); - private::directory(&logs).unwrap(); - let path = logs.join("casework.log"); - let old_marker = b"latest-before-restart\n"; - let mut oversized = vec![b'o'; MAX_BYTES as usize + 1024]; - oversized.extend_from_slice(old_marker); - private::create(&path, &oversized).unwrap(); - - let journal = RetainedJournal::open(&path).unwrap(); - drop(journal); - let compacted = fs::read(&path).unwrap(); - assert!(compacted.len() <= MAX_BYTES as usize); - assert!(compacted.ends_with(old_marker)); - - let new_marker = b"latest-during-service\n"; - let mut service_output = vec![b'n'; MAX_BYTES as usize + 1024]; - service_output.extend_from_slice(new_marker); - pump_retained( - std::io::Cursor::new(service_output), - RetainedJournal::open(&path).unwrap(), - ) - .unwrap(); - let after_service = fs::read(&path).unwrap(); - assert!(after_service.len() <= MAX_BYTES as usize); - assert!(after_service.ends_with(new_marker)); - - let restart_marker = b"latest-after-restart\n"; - pump_retained( - std::io::Cursor::new(restart_marker), - RetainedJournal::open(&path).unwrap(), - ) - .unwrap(); - let after_restart = fs::read(&path).unwrap(); - assert!(after_restart.len() <= MAX_BYTES as usize); - assert!(after_restart.ends_with(restart_marker)); - assert!(after_restart - .windows(new_marker.len()) - .any(|window| window == new_marker)); - let metadata = fs::symlink_metadata(&path).unwrap(); - assert_eq!(metadata.permissions().mode() & 0o077, 0); - assert_eq!(metadata.nlink(), 1); -} - -#[test] -fn supervisor_log_stays_bounded_and_resists_path_swaps_and_links() { - let root = tempfile::tempdir().unwrap(); - let logs = root.path().join("logs"); - private::directory(&logs).unwrap(); - let path = logs.join("supervisor.log"); - let old_marker = b"latest-previous-supervisor-failure\n"; - let mut oversized = vec![b'o'; MAX_BYTES as usize + 1024]; - oversized.extend_from_slice(old_marker); - private::create(&path, &oversized).unwrap(); - let new_error = anyhow::anyhow!( - "latest-current-supervisor-failure:{}", - "\u{1f980}".repeat(MAX_REFUSAL) - ); - let bounded = bounded_supervisor_error(&new_error); - - let mut log = supervisor_log(root.path()).unwrap(); - writeln!(log, "{bounded}").unwrap(); - drop(log); - - let retained = fs::read(&path).unwrap(); - assert!(retained.len() <= MAX_BYTES as usize); - assert!(retained - .windows(old_marker.len()) - .any(|window| window == old_marker)); - assert!(String::from_utf8_lossy(&retained).contains("latest-current-supervisor-failure")); - assert_eq!(bounded.chars().count(), MAX_REFUSAL); - let maximum_width = - bounded_supervisor_error(&anyhow::anyhow!("{}", "\u{1f980}".repeat(MAX_REFUSAL + 1))); - // `main_entry` writes this string directly with `eprintln!("{error:#}")`: - // no prefix, and exactly one framing newline. - assert_eq!(maximum_width.len() + 1, MAX_SUPERVISOR_ERROR_BYTES as usize); - let metadata = fs::symlink_metadata(&path).unwrap(); - assert_eq!(metadata.permissions().mode() & 0o077, 0); - assert_eq!(metadata.nlink(), 1); - - let swap_root = tempfile::tempdir().unwrap(); - let swap_logs = swap_root.path().join("logs"); - let moved_logs = swap_root.path().join("original-logs"); - let replacement_logs = swap_root.path().join("replacement-logs"); - private::directory(&swap_logs).unwrap(); - private::directory(&replacement_logs).unwrap(); - private::create( - &swap_logs.join("supervisor.log"), - b"original recent failure\n", - ) - .unwrap(); - private::create( - &replacement_logs.join("supervisor.log"), - b"replacement must stay unchanged\n", - ) - .unwrap(); - let mut log = supervisor_log_with_open(swap_root.path(), || { - fs::rename(&swap_logs, &moved_logs).unwrap(); - fs::rename(&replacement_logs, &swap_logs).unwrap(); - }) - .unwrap(); - writeln!(log, "current failure").unwrap(); - drop(log); - assert_eq!( - fs::read(swap_logs.join("supervisor.log")).unwrap(), - b"replacement must stay unchanged\n" - ); - assert_eq!( - fs::read(moved_logs.join("supervisor.log")).unwrap(), - b"original recent failure\ncurrent failure\n" - ); - - let hardlink_root = tempfile::tempdir().unwrap(); - let hardlink_logs = hardlink_root.path().join("logs"); - private::directory(&hardlink_logs).unwrap(); - let target = hardlink_logs.join("target.log"); - private::create(&target, b"preserve me").unwrap(); - let linked = hardlink_logs.join("supervisor.log"); - fs::hard_link(&target, &linked).unwrap(); - let refusal = format!("{:#}", supervisor_log(hardlink_root.path()).unwrap_err()); - assert!(refusal.contains("single-link"), "{refusal}"); - assert_eq!(fs::read(&target).unwrap(), b"preserve me"); - - let symlink_root = tempfile::tempdir().unwrap(); - let symlink_logs = symlink_root.path().join("logs"); - private::directory(&symlink_logs).unwrap(); - let target = symlink_logs.join("target.log"); - private::create(&target, b"preserve me too").unwrap(); - let linked = symlink_logs.join("supervisor.log"); - std::os::unix::fs::symlink(&target, &linked).unwrap(); - let refusal = format!("{:#}", supervisor_log(symlink_root.path()).unwrap_err()); - assert!(refusal.contains("supervisor journal"), "{refusal}"); - assert_eq!(fs::read(&target).unwrap(), b"preserve me too"); -} - -#[test] -fn invalid_service_journal_is_refused_before_the_child_starts() { - let root = tempfile::tempdir().unwrap(); - let logs = root.path().join("logs"); - private::directory(&logs).unwrap(); - let journal = logs.join("casework.log"); - fs::write(&journal, b"").unwrap(); - fs::set_permissions(&journal, fs::Permissions::from_mode(0o644)).unwrap(); - let marker = root.path().join("child-started"); + let project = standalone(root.path()); + let mut state = session(&project); + let clients = Clients { + version: 1, + clients: vec![ + config::Client { + id: "administrator".to_owned(), + access_profile: "administrator".to_owned(), + scopes: vec!["casework:admin".to_owned()], + claims: BTreeMap::new(), + }, + config::Client { + id: "requester".to_owned(), + access_profile: "requester".to_owned(), + scopes: vec!["casework:request".to_owned()], + claims: BTreeMap::new(), + }, + ], + directory: Vec::new(), + integrations: None, + }; + state.clients = vec![ + ReportedClient { + id: "administrator".to_owned(), + profile: "administrator".to_owned(), + role: CaseworkRole::Administrator, + principal: config::principal("administrator"), + }, + ReportedClient { + id: "requester".to_owned(), + profile: "requester".to_owned(), + role: CaseworkRole::Requester, + principal: config::principal("requester"), + }, + ]; + let terminate = AtomicBool::new(false); + let mut issued = Vec::new(); let refusal = format!( "{:#}", - service( - Path::new("/usr/bin/touch"), - &[], - &marker, - &[], - root.path(), - "casework", - ) - .err() - .expect("unsafe journal must be refused") + issue_tokens(&state, &clients, &terminate, |id| { + issued.push(id.to_owned()); + terminate.store(true, Ordering::Relaxed); + Ok(()) + }) + .unwrap_err() ); - thread::sleep(Duration::from_millis(100)); - assert!(refusal.contains("owner-only"), "{refusal}"); - assert!(!marker.exists()); + assert!(refusal.contains("interrupted"), "{refusal}"); + assert_eq!(issued, ["requester"]); } #[test] -fn service_guard_process_helper() { - let Some(encoded) = std::env::var_os("CASEWORKCTL_TEST_SERVICE_GUARD_ARGV") else { - return; +fn service_cleanup_joins_every_log_pump() { + let child = Command::new("/usr/bin/true") + .process_group(0) + .spawn() + .unwrap(); + let joined = Arc::new(AtomicBool::new(false)); + let marker = Arc::clone(&joined); + let pump = thread::spawn(move || { + thread::sleep(Duration::from_millis(20)); + marker.store(true, Ordering::Relaxed); + Ok(()) + }); + let mut children = Children { + casework: Some(Service::from_guard(child, vec![pump]).unwrap()), }; - let mut arguments: Vec = serde_json::from_str(&encoded.to_string_lossy()).unwrap(); - let binary = arguments.remove(0); - let interruption = StartInterruption::install().unwrap(); - let mut command = Command::new(binary); - command.args(arguments).stdin(Stdio::null()); - let forced = guard_service_command( - command, - std::io::stdin(), - Arc::clone(&interruption.requested), - Duration::from_millis(500), - Duration::from_millis(500), - ) - .unwrap(); - if forced { - std::process::exit(i32::from(SERVICE_GUARD_FORCED_EXIT)); + let deadline = Instant::now() + Duration::from_secs(2); + while !children.exited().unwrap() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); } + assert!(children.exited().unwrap(), "guard did not become waitable"); + + children.stop().unwrap(); + assert!(joined.load(Ordering::Relaxed)); } #[test] -fn service_guard_supervisor_helper() { - let Some(root) = std::env::var_os("CASEWORKCTL_TEST_GUARD_ROOT").map(PathBuf::from) else { - return; - }; - let binary = PathBuf::from(std::env::var_os("CASEWORKCTL_TEST_GUARD_BINARY").unwrap()); - private::directory(&root.join("logs")).unwrap(); - let _service = service( - &binary, - &[], - &root.join("service.pid"), - &[], - &root, - "guarded", - ) - .unwrap(); - loop { - thread::sleep(Duration::from_secs(1)); +fn legacy_issuer_state_and_unsafe_token_clients_are_refused_without_effects() { + let root = tempfile::tempdir().unwrap(); + let project = standalone(root.path()); + let state = session(&project); + private::directory(&project.join(".casework")).unwrap(); + private::directory(&state.root()).unwrap(); + let mut legacy = serde_json::to_value(&state).unwrap(); + legacy["version"] = json!(1); + let bytes = serde_json::to_vec(&legacy).unwrap(); + private::create(&state.root().join("state.json"), &bytes).unwrap(); + assert!(read_state(&state.root()) + .unwrap_err() + .to_string() + .contains("uses Mint")); + assert_eq!(fs::read(state.root().join("state.json")).unwrap(), bytes); + for id in ["../staff", "/staff", "", "staff/header"] { + assert!(fresh_token(&project, id) + .unwrap_err() + .to_string() + .contains("bounded local client")); } } #[test] -fn nonzero_outer_guard_helper() { - let Some(service_pid_file) = - std::env::var_os("CASEWORKCTL_TEST_NONZERO_GUARD_PID").map(PathBuf::from) - else { - return; - }; - let service_binary = std::env::var_os("CASEWORKCTL_TEST_NONZERO_GUARD_SERVICE").unwrap(); - let _child = Command::new(service_binary) - .arg(&service_pid_file) - .spawn() - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - assert!( - file_has_bytes(&service_pid_file), - "service did not become ready" - ); - std::process::exit(23); +fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { + let args = [ + "caseworkctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001", + "--connection", + "/tmp/task-connection.yaml", + "/tmp/project", + ]; + assert!(::try_parse_from(args).is_ok()); + assert!(::try_parse_from([ + "caseworkctl", + "dev", + "grant", + "task-agent", + "--grant", + "01970000-0000-7000-8000-000000000001" + ]) + .is_err()); + let mut arbitrary = args.to_vec(); + arbitrary.extend(["--purpose", "invented"]); + assert!(::try_parse_from(arbitrary).is_err()); } #[test] -fn guarded_service_stops_after_its_supervisor_is_killed() { - let root = tempfile::tempdir().unwrap(); - let binary = root.path().join("service.sh"); - fs::write( - &binary, - b"#!/bin/sh\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", +fn explicit_local_integrations_render_only_governed_authority_and_bind_the_source() { + let workspace = tempfile::tempdir().unwrap(); + let project = standalone(workspace.path()); + let mut policy = crate::project::load_and_check_policy(&project).unwrap(); + policy.sources.push(serde_json::from_value(json!({"id":"source","adapter":"breg","description":"source.json","requests":[{"entity":"correction","queue":"decisions"}]})).unwrap()); + let mut clients = config::clients(STANDALONE_DEV_CLIENTS.as_bytes()).unwrap(); + let integrations: integrations::Integrations = serde_json::from_value(json!({ + "resource":"urn:casework:source-group", + "sources":{"source":{"baseUrl":"http://127.0.0.1:8800","readerProfile":"reader", + "tokenEndpoint":"http://127.0.0.1:8093/oauth2/token","clientAssertionAudience":"http://127.0.0.1:8093", + "resource":"urn:casework:source-group","scopes":["records:get"], + "clientIdRef":"secret:file/service-reader-id","clientAssertionKeyRef":"secret:file/service-reader-key", + "webhookSecretRef":"secret:file/source-webhook","eventSource":"urn:registrystack:registry:source:instance:local"}}, + "serviceClients":[ + {"id":"reader","scopes":["records:get"]}, + {"id":"task-agent","scopes":["casework:grants:assert"],"taskExchange":true}, + {"id":"status","scopes":["casework:grants:status"]}], + "taskAuthority":{"id":"casework","issuer":"https://casework.local.example","jwksPort":8801,"statusClients":{"status":"urn:casework:source-group"}} + })).unwrap(); + clients.integrations = Some(integrations.clone()); + integrations.validate(&clients, &policy).unwrap(); + let mut state = session(&project); + state.resource = Some(integrations.resource.clone()); + integrations.validate_session(&state, &policy).unwrap(); + let root = project.join("private"); + private::directory(&root).unwrap(); + for name in ["issuer", "secrets", "credentials"] { + private::directory(&root.join(name)).unwrap(); + } + let key = config::keypair(&root.join("human")).unwrap(); + let mut description = registry_thunderid_tooling::local::local_description( + registry_thunderid_tooling::description::SessionIdentity { + label: "source-unit".into(), + id: "casework-local".into(), + }, + state.issuer_port, + root.join("issuer"), + state.audience(), + vec![registry_thunderid_tooling::local::LocalClient { + client_id: "staff".into(), + public_jwks: json!({"keys":[key]}).to_string(), + claims: BTreeMap::new(), + scopes: vec!["casework:staff".into()], + allow_human_fixture: true, + }], ) .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut supervisor = Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "dev::tests::service_guard_supervisor_helper", - "--nocapture", - ]) - .env("CASEWORKCTL_TEST_GUARD_ROOT", root.path()) - .env("CASEWORKCTL_TEST_GUARD_BINARY", &binary) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() + integrations + .prepare(&root, &state, &mut description, &policy) .unwrap(); - let service_pid_file = root.path().join("service.pid"); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(10)); - } - if !file_has_bytes(&service_pid_file) { - supervisor.kill().unwrap(); - supervisor.wait().unwrap(); - panic!("guarded service did not start"); - } - let service_pid = fs::read_to_string(&service_pid_file) - .unwrap() - .parse::() + let agent = description + .machine_clients + .iter() + .find(|client| client.client_id == "task-agent") .unwrap(); - let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - let started = Instant::now(); - - // SIGKILL skips every supervisor destructor. The kernel still closes the - // supervisor's liveness writer, which must stop the exact guarded child. - supervisor.kill().unwrap(); - supervisor.wait().unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while rustix::process::test_kill_process(service_pid).is_ok() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(10)); - } - let stopped = rustix::process::test_kill_process(service_pid).is_err(); - if !stopped { - rustix::process::kill_process(service_pid, rustix::process::Signal::KILL).unwrap(); - } - - assert!(stopped, "guarded service survived supervisor death"); - assert!(started.elapsed() < Duration::from_secs(5)); -} - -#[test] -fn service_guard_owns_a_stubborn_child_during_startup_interruption() { - let root = tempfile::tempdir().unwrap(); - let binary = root.path().join("stubborn.sh"); - let service_pid_file = root.path().join("service.pid"); + assert_eq!(agent.agent_id, config::principal("task-agent")); + assert!(agent.token_exchange.is_some()); + assert_eq!(agent.attributes["registry_actor_kind"], "agent"); + assert!(!agent + .attributes + .keys() + .any(|name| name.starts_with("registry_grant_"))); + let mut operator = config::operator(&state); + integrations + .operator(&state, &clients, &mut operator) + .unwrap(); + assert_eq!( + operator["taskAuthority"]["issuer"], + "https://casework.local.example" + ); + assert_eq!(operator["sources"]["source"]["resource"], state.audience()); + assert_eq!(description.exchange_issuers.len(), 1); + let mut wrong = integrations.clone(); + wrong.sources.get_mut("source").unwrap().resource = Some("urn:other".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong + .sources + .get_mut("source") + .unwrap() + .client_assertion_audience = Some("https://other.example".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[1].scopes.push("records:get".into()); + assert!(wrong.validate(&clients, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[0] + .claims + .insert("registry_actor_kind".into(), json!("human")); + assert!(wrong.validate(&clients, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong + .sources + .get_mut("source") + .unwrap() + .client_assertion_key_ref = "secret:file/service-status-key".into(); + assert!(wrong.validate_session(&state, &policy).is_err()); + let mut wrong = integrations.clone(); + wrong.service_clients[0].scopes.push("records:patch".into()); + assert!(wrong.validate_session(&state, &policy).is_err()); + // Invalid binding input never installs a partially initialized session. fs::write( - &binary, - b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", + project.join("casework.yaml"), + serde_norway::to_string(&policy).unwrap(), ) .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let (reader, writer) = UnixStream::pair().unwrap(); - let terminate = Arc::new(AtomicBool::new(false)); - let request = Arc::clone(&terminate); - let marker = service_pid_file.clone(); - let requester = thread::spawn(move || { - let deadline = Instant::now() + Duration::from_secs(5); - while !marker.exists() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - let ready = marker.exists(); - if ready { - request.store(true, Ordering::Relaxed); - } - // EOF is also a cleanup request if readiness failed, so the assertion - // below cannot strand whatever the guard managed to spawn. - drop(writer); - ready - }); - let mut command = Command::new(&binary); - command.arg(&service_pid_file).stdin(Stdio::null()); - - let forced = guard_service_command( - command, - reader, - terminate, - Duration::from_millis(50), - Duration::from_millis(50), + fs::write(project.join("source.json"), serde_json::to_vec(&json!({ + "apiVersion":"registry.registrystack.org/casework-source-description/v1alpha1", + "kind":"BRegCaseworkSourceDescription","origin":"bregctl explain change-requests","authority":"none", + "sourceId":"source","sourceRevision":"sha256:source", + "request":{"requestEntity":"correction","requestRoute":"corrections","reviewMode":"staged", + "stages":[{"id":"review","approvals":1,"excludeSubmitter":true,"excludePreviousReviewers":false}], + "fields":[],"contractFingerprint":"sha256:contract","application":{"mode":"manual"}} + })).unwrap()).unwrap(); + let client_bytes = serde_norway::to_string(&clients).unwrap(); + assert!(capture_with_sources(&project, client_bytes.as_bytes(), &[], &BTreeMap::new()).is_ok()); + assert!(capture_with_sources( + &project, + client_bytes.as_bytes(), + &["source=/tmp/registry".into()], + &BTreeMap::new() ) - .unwrap(); - let ready = requester.join().unwrap(); - assert!( - ready, - "stubborn service did not reach its startup handshake" - ); - let service_pid = fs::read_to_string(&service_pid_file) + .unwrap_err() + .to_string() + .contains("explicit integrations")); + let webhook = root.join("webhook-input"); + private::create(&webhook, b"synthetic-webhook-secret-at-least-32-bytes").unwrap(); + let mut staged_integrations = integrations.clone(); + staged_integrations + .secret_files + .insert("source-webhook".into(), webhook); + staged_integrations + .sources + .get_mut("source") .unwrap() - .parse::() - .unwrap(); - let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - - assert!(forced); - assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); -} - -#[test] -fn service_guard_does_not_force_kill_after_a_fast_term_exit() { - let (reader, writer) = UnixStream::pair().unwrap(); - drop(writer); - let mut command = Command::new("/bin/sleep"); - command.arg("5").stdin(Stdio::null()); - - let forced = guard_service_command( - command, - reader, - Arc::new(AtomicBool::new(false)), - Duration::from_millis(100), - Duration::from_millis(100), - ) - .unwrap(); - - assert!(!forced); -} - -#[test] -fn established_service_keeps_its_graceful_shutdown_window() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let binary = root.path().join("service.sh"); - let graceful = root.path().join("graceful"); - fs::write( - &binary, - b"#!/bin/sh\ntrap 'sleep 0.2; printf graceful > \"$2\"; exit 0' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", - ) - .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let service_pid_file = root.path().join("service.pid"); - let mut service = service( - &binary, - &[], - &service_pid_file, - &[graceful.to_str().unwrap()], - root.path(), - "guarded", - ) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - if !file_has_bytes(&service_pid_file) { - let _ = service.stop(); - panic!("service did not reach its startup handshake"); - } - - let started = Instant::now(); - service.stop().unwrap(); - - assert!(graceful.exists(), "guardian truncated graceful shutdown"); - assert!(started.elapsed() >= Duration::from_millis(150)); - assert!(started.elapsed() < Duration::from_secs(1)); -} - -#[test] -fn established_stubborn_service_reports_forced_shutdown() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let binary = root.path().join("stubborn.sh"); - let service_pid_file = root.path().join("service.pid"); - fs::write( - &binary, - b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", - ) - .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut service = - service(&binary, &[], &service_pid_file, &[], root.path(), "guarded").unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - if !file_has_bytes(&service_pid_file) { - let _ = service.stop(); - panic!("stubborn service did not reach its startup handshake"); - } - let service_pid = fs::read_to_string(&service_pid_file) + .event_source = "urn:invalid:event".into(); + clients.integrations = Some(staged_integrations); + parent_directory(&project).unwrap(); + assert!(initialize(&state.root(), &state, &clients).is_err()); + assert!(!state.root().exists()); + clients + .integrations + .as_mut() .unwrap() - .parse::() - .unwrap(); - let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - - let refusal = format!("{:#}", service.stop().unwrap_err()); - - assert!(refusal.contains("required forced shutdown"), "{refusal}"); - assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); + .sources + .get_mut("source") + .unwrap() + .event_source = "urn:registrystack:registry:source:instance:local".into(); + initialize(&state.root(), &state, &clients).unwrap(); + assert!(state.root().join("operator.yaml").exists()); + let mut wrong = integrations; + wrong.sources.clear(); + assert!(wrong.validate(&clients, &policy).is_err()); } -#[test] -fn killed_guard_leaves_the_supervisor_to_clean_its_exact_service_group() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let binary = root.path().join("stubborn.sh"); - let service_pid_file = root.path().join("service.pid"); - fs::write( - &binary, - b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", - ) - .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut service = - service(&binary, &[], &service_pid_file, &[], root.path(), "guarded").unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - assert!( - file_has_bytes(&service_pid_file), - "guarded service did not start" - ); - let service_pid = rustix::process::Pid::from_raw( - fs::read_to_string(&service_pid_file) - .unwrap() - .parse::() - .unwrap(), - ) - .unwrap(); - assert_eq!( - rustix::process::getpgid(Some(service_pid)).unwrap(), - service.guard_pgid, - "the actual service must inherit the guard's pinned group" - ); - - rustix::process::kill_process(service.guard_pid, rustix::process::Signal::KILL).unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - while service.guard_exit().unwrap().is_none() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - assert!( - service.guard_exit().unwrap().is_some(), - "killed guard did not become waitable" - ); - assert!( - rustix::process::test_kill_process(service_pid).is_ok(), - "the regression requires a service left alive by its killed guard" - ); - - let refusal = format!( - "{:#}", - service - .stop_with_grace(Duration::from_millis(100), Duration::from_millis(25)) - .unwrap_err() - ); - - assert!(refusal.contains("guard exited abnormally"), "{refusal}"); - assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); +struct RegistrySession { + _root: tempfile::TempDir, + executable: PathBuf, + project: PathBuf, + calls: PathBuf, } -#[test] -fn nonzero_guard_exit_is_detected_without_waiting_for_pump_eof() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let service_pid_file = root.path().join("service.pid"); - let service_binary = root.path().join("service.sh"); - fs::write( - &service_binary, - b"#!/bin/sh\ntrap '' HUP TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", - ) - .unwrap(); - fs::set_permissions(&service_binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut guard = Command::new(std::env::current_exe().unwrap()); - guard - .args([ - "--exact", - "dev::tests::nonzero_outer_guard_helper", - "--nocapture", - ]) - .env("CASEWORKCTL_TEST_NONZERO_GUARD_PID", &service_pid_file) - .env("CASEWORKCTL_TEST_NONZERO_GUARD_SERVICE", &service_binary); - let mut service = service_with_guard_command(guard, root.path(), "guarded").unwrap(); - // The helper itself permits five seconds for the service PID file. Give - // the parent that complete startup budget plus scheduling margin when the - // full test suite is running concurrently. - let deadline = Instant::now() + Duration::from_secs(6); - while (!file_has_bytes(&service_pid_file) || service.guard_exit().unwrap().is_none()) - && Instant::now() < deadline - { - thread::sleep(Duration::from_millis(5)); - } - assert!( - file_has_bytes(&service_pid_file), - "guard did not create its service" - ); - assert!( - service.guard_exit().unwrap().is_some(), - "nonzero guard did not become waitable" - ); - let service_pid = rustix::process::Pid::from_raw( - fs::read_to_string(&service_pid_file) - .unwrap() - .parse::() +impl RegistrySession { + fn create_project(root: &Path, name: &str) -> PathBuf { + let project = root.join(name); + fs::create_dir(&project).unwrap(); + fs::write( + project.join("registry.yaml"), + serde_json::to_vec(&json!({ + "registry": {"id": "professional-licences"}, + "package": {"instanceId": "professional-licences-starter"} + })) .unwrap(), - ) - .unwrap(); - assert!(rustix::process::test_kill_process(service_pid).is_ok()); - let started = Instant::now(); - - let refusal = format!( - "{:#}", - service - .stop_with_grace(Duration::from_millis(50), Duration::from_millis(10)) - .unwrap_err() - ); - - assert!(refusal.contains("guard exited abnormally"), "{refusal}"); - assert!(started.elapsed() < Duration::from_secs(1)); - assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); -} - -#[test] -fn live_guard_timeout_kills_the_pinned_group_before_reaping() { - let root = tempfile::tempdir().unwrap(); - private::directory(&root.path().join("logs")).unwrap(); - let guard_binary = root.path().join("guard.sh"); - let service_pid_file = root.path().join("service.pid"); - // Publish the descendant PID from the guard that created it. This proves - // the group member exists without depending on when that child is scheduled. - fs::write( - &guard_binary, - b"#!/bin/sh\ntrap '' TERM\n/bin/sleep 60 &\nprintf '%s' \"$!\" > \"$1\"\nexec /bin/sleep 60\n", - ) - .unwrap(); - fs::set_permissions(&guard_binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut guard = Command::new(&guard_binary); - guard.arg(&service_pid_file); - let mut service = service_with_guard_command(guard, root.path(), "guarded").unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); + ) + .unwrap(); + fs::write( + project.join(".fixture-token-endpoint"), + "http://127.0.0.1:8191/oauth2/token", + ) + .unwrap(); + fs::write(project.join(".fixture-audience"), "urn:breg:dev:fixture").unwrap(); + fs::write(project.join(".fixture-credential"), "shared-key").unwrap(); + fs::canonicalize(project).unwrap() } - if !file_has_bytes(&service_pid_file) { - let _ = service.stop_with_grace(Duration::from_millis(75), Duration::from_millis(10)); - panic!("guarded service did not start"); + + fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let project = Self::create_project(root.path(), "registry"); + let executable = root.path().join("bregctl"); + fs::write( + &executable, + br#"#!/bin/sh +set -eu +fixture=$(dirname "$0") +printf '%s +' "$*" >> "$fixture/calls" +project="" +client="" +id_file="" +key_file="" +while [ $# -gt 0 ]; do + case "$1" in + export-client) project=$2; shift 2;; + --client) client=$2; shift 2;; + --client-id-file) id_file=$2; shift 2;; + --assertion-key-file) key_file=$2; shift 2;; + *) shift;; + esac +done +audience=$(cat "$project/.fixture-audience") +token_endpoint=$(cat "$project/.fixture-token-endpoint") +credential=$(cat "$project/.fixture-credential") +case "$client" in + casework-reader) scopes='["casework:source-reader"]';; + administrator) scopes='["casework:admin"]';; + supervisor) scopes='["casework:supervisor","starter:reviewer"]';; + staff) scopes='["casework:staff","starter:reviewer"]';; + requester) scopes='["casework:request"]';; + *) scopes='["casework:fixture"]';; +esac +umask 077 +printf '%s' "$client" > "$id_file" +printf '{"kty":"EC","fixture":"%s"}' "$credential" > "$key_file" +issuer=${token_endpoint%/oauth2/token} +printf '{"ok":true,"command":"dev export-client","client":"%s","bregUrl":"http://127.0.0.1:8090","tokenEndpoint":"%s","clientAssertionAudience":"%s","resource":"%s","audience":"%s","scopes":%s} +' "$client" "$token_endpoint" "$issuer" "$audience" "$audience" "$scopes" +"#, + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + Self { + calls: root.path().join("calls"), + _root: root, + executable, + project, + } } - let service_pid = rustix::process::Pid::from_raw( - fs::read_to_string(&service_pid_file) - .unwrap() - .parse::() - .unwrap(), - ) - .unwrap(); - assert!(rustix::process::test_kill_process(service_pid).is_ok()); - assert_eq!( - rustix::process::getpgid(Some(service_pid)).unwrap(), - service.guard_pgid, - "the descendant must join the guard's pinned group" - ); - let started = Instant::now(); - - let refusal = format!( - "{:#}", - service - .stop_with_grace(Duration::from_millis(75), Duration::from_millis(10)) - .unwrap_err() - ); - assert!(refusal.contains("required forced shutdown"), "{refusal}"); - assert!(started.elapsed() >= Duration::from_millis(70)); - assert!(started.elapsed() < Duration::from_secs(1)); - assert!(wait_for_process_exit(service_pid, Duration::from_secs(2))); -} + fn add_project(&self, name: &str) -> PathBuf { + Self::create_project(self._root.path(), name) + } -#[test] -fn post_kill_wait_is_bounded_when_a_guard_does_not_become_waitable() { - let mut guard = Command::new("/bin/sleep") - .arg("5") - .process_group(0) - .spawn() - .unwrap(); - let guard_pid = rustix::process::Pid::from_raw(guard.id() as i32).unwrap(); - let started = Instant::now(); + fn calls(&self) -> Vec { + fs::read_to_string(&self.calls) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } - // Model a kernel reporting successful group KILL without making the guard - // waitable. Cleanup must return at its own bound instead of entering a - // blocking Child::wait. - let refusal = format!( - "{:#}", - kill_guard_group_and_reap_with(&mut guard, guard_pid, Duration::from_millis(40), |_pgid| { - Ok(()) - },) - .unwrap_err() - ); + fn set_audience(project: &Path, audience: &str) { + fs::write(project.join(".fixture-audience"), audience).unwrap(); + } - assert!(refusal.contains("bounded cleanup wait"), "{refusal}"); - assert!(started.elapsed() >= Duration::from_millis(35)); - assert!(started.elapsed() < Duration::from_secs(1)); - assert!(guard_exit(guard_pid).unwrap().is_none()); - rustix::process::kill_process(guard_pid, rustix::process::Signal::KILL).unwrap(); - guard.wait().unwrap(); + fn set_credentials(project: &Path, credential: &str) { + fs::write(project.join(".fixture-credential"), credential).unwrap(); + } } -#[test] -fn failed_group_kill_never_enters_a_blocking_guard_wait() { - let mut guard = Command::new("/bin/sleep") - .arg("5") - .process_group(0) - .spawn() - .unwrap(); - let guard_pid = rustix::process::Pid::from_raw(guard.id() as i32).unwrap(); - let started = Instant::now(); - - let refusal = format!( - "{:#}", - kill_guard_group_and_reap_with(&mut guard, guard_pid, Duration::from_secs(1), |_pgid| Err( - anyhow::anyhow!("injected group KILL failure") - ),) - .unwrap_err() - ); - - assert!(refusal.contains("cannot KILL"), "{refusal}"); - assert!(refusal.contains("injected group KILL failure"), "{refusal}"); - assert!(started.elapsed() < Duration::from_millis(100)); - assert!(guard_exit(guard_pid).unwrap().is_none()); - rustix::process::kill_process(guard_pid, rustix::process::Signal::KILL).unwrap(); - guard.wait().unwrap(); +fn prepare_source_export_destinations(state: &State, clients: &Clients) { + let root = state.root(); + for directory in ["credentials", "secrets"] { + private::directory(&root.join(directory)).unwrap(); + } + for client in &clients.clients { + private::directory(&root.join("credentials").join(&client.id)).unwrap(); + } } -#[test] -fn service_pump_setup_failures_reap_the_child_and_join_started_pumps() { - let root = tempfile::tempdir().unwrap(); - let logs = root.path().join("logs"); - private::directory(&logs).unwrap(); - for fail_on in [1, 2] { - let journal = RetainedJournal::open(&logs.join("casework.log")).unwrap(); - let child = Command::new("/bin/sh") - .args(["-c", "read ignored || exit 0"]) - .process_group(0) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let pid = rustix::process::Pid::from_raw(child.id() as i32).unwrap(); - let joined = Arc::new(AtomicBool::new(false)); - let mut calls = 0; - let started = Instant::now(); - - let refusal = format!( - "{:#}", - service_with_pump_spawner(child, journal, |_stream, task| { - calls += 1; - if calls == fail_on { - return Err(std::io::Error::other("injected pump spawn failure")); - } - let joined = Arc::clone(&joined); - thread::Builder::new().spawn(move || { - let result = task(); - joined.store(true, Ordering::Relaxed); - result - }) - }) - .err() - .expect("selected pump spawn must fail") - ); - - let reader = if fail_on == 1 { "output" } else { "diagnostic" }; - assert!(refusal.contains(reader), "{refusal}"); - assert!(started.elapsed() < Duration::from_secs(1)); - assert_eq!(joined.load(Ordering::Relaxed), fail_on == 2); - assert!(rustix::process::test_kill_process(pid).is_err()); +fn retained_credential_canaries(state: &State, clients: &Clients) -> BTreeMap> { + let root = state.root(); + let mut canaries = BTreeMap::new(); + for client in &clients.clients { + let directory = root.join("credentials").join(&client.id); + for (name, bytes) in [ + ("client-id", b"RETAINED-CLIENT-ID".as_slice()), + ( + "assertion-key.jwk", + b"RETAINED-CLIENT-ASSERTION-KEY".as_slice(), + ), + ] { + let path = directory.join(name); + private::create(&path, bytes).unwrap(); + canaries.insert(path, bytes.to_vec()); + } + } + for id in state.sources.keys() { + for (suffix, bytes) in [ + ("reader-client-id", b"RETAINED-READER-ID".as_slice()), + ( + "reader-assertion-key.jwk", + b"RETAINED-READER-ASSERTION-KEY".as_slice(), + ), + ("webhook-key", b"RETAINED-WEBHOOK-KEY".as_slice()), + ] { + let path = root.join("secrets").join(format!("{id}-{suffix}")); + private::create(&path, bytes).unwrap(); + canaries.insert(path, bytes.to_vec()); + } } + canaries } #[test] -fn guardian_pump_setup_failure_reaps_a_stubborn_owned_service() { - let root = tempfile::tempdir().unwrap(); - let logs = root.path().join("logs"); - private::directory(&logs).unwrap(); - let binary = root.path().join("stubborn.sh"); - let service_pid_file = root.path().join("service.pid"); - fs::write( - &binary, - b"#!/bin/sh\ntrap '' TERM\nprintf '%s' \"$$\" > \"$1\"\nwhile :; do sleep 0.02; done\n", - ) - .unwrap(); - fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); - let mut guardian = - service_guard_command(&binary, &[service_pid_file.as_os_str().to_owned()]).unwrap(); - let child = guardian - .process_group(0) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let journal = RetainedJournal::open(&logs.join("casework.log")).unwrap(); - let started = Instant::now(); - let mut ready = false; +fn binding_a_source_exports_the_reader_and_every_person_from_the_registry_session() { + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let registry = RegistrySession::new(); + let mut state = persisted_session(&project); + let root = state.root(); + let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); + for directory in ["credentials", "secrets"] { + private::directory(&root.join(directory)).unwrap(); + } + for client in &clients.clients { + private::directory(&root.join("credentials").join(&client.id)).unwrap(); + } + state.sources.insert( + "professional-register".into(), + SourceSession { + project: registry.project.clone(), + binding: None, + }, + ); - let refusal = format!( - "{:#}", - service_with_pump_spawner(child, journal, |_stream, _task| { - let deadline = Instant::now() + Duration::from_secs(5); - while !file_has_bytes(&service_pid_file) && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); - } - ready = file_has_bytes(&service_pid_file); - Err(std::io::Error::other("injected pump spawn failure")) - }) - .err() - .expect("injected guardian pump spawn must fail") + export_sources(®istry.executable, &mut state, &clients).unwrap(); + + let binding = state.sources["professional-register"] + .binding + .as_ref() + .unwrap(); + assert_eq!(binding.breg_url, "http://127.0.0.1:8090"); + assert_eq!(binding.token_endpoint, "http://127.0.0.1:8191/oauth2/token"); + assert_eq!(binding.audience, "urn:breg:dev:fixture"); + assert_eq!( + binding.event_source, + "urn:registrystack:registry:professional-licences:instance:professional-licences-starter" + ); + assert_eq!( + fs::read_to_string(root.join("secrets/professional-register-reader-client-id")).unwrap(), + "casework-reader" + ); + assert!(file_has_bytes( + &root.join("secrets/professional-register-reader-assertion-key.jwk") + )); + let webhook = + fs::read_to_string(root.join("secrets/professional-register-webhook-key")).unwrap(); + assert_eq!(webhook.len(), 64); + for client in &clients.clients { + let directory = root.join("credentials").join(&client.id); + assert_eq!( + fs::read_to_string(directory.join("client-id")).unwrap(), + client.id + ); + assert!(file_has_bytes(&directory.join("assertion-key.jwk"))); + } + let calls = registry.calls(); + assert_eq!(calls.len(), 1 + clients.clients.len(), "{calls:?}"); + let prefix = format!( + "--format json dev export-client {} --client ", + registry.project.display() ); assert!( - ready, - "stubborn service did not reach its startup handshake" + calls[0].starts_with(&format!("{prefix}casework-reader ")), + "{}", + calls[0] + ); + for client in &clients.clients { + assert!( + calls + .iter() + .any(|call| call.starts_with(&format!("{prefix}{} ", client.id))), + "{calls:?}" + ); + } + // The retained state carries the binding across restarts. + assert_eq!( + read_state(&root).unwrap().sources["professional-register"].binding, + state.sources["professional-register"].binding ); - let service_pid = fs::read_to_string(&service_pid_file) - .unwrap() - .parse::() - .unwrap(); - let service_pid = rustix::process::Pid::from_raw(service_pid).unwrap(); - - assert!(refusal.contains("output reader"), "{refusal}"); - assert!(started.elapsed() < Duration::from_secs(5)); - assert!(rustix::process::test_kill_process(service_pid).is_err()); -} - -#[test] -fn seeding_administrator_token_is_issued_after_every_other_client() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let mut state = session(&project); - let clients = Clients { - version: 1, - clients: (0..32) - .map(|index| config::Client { - id: if index == 0 { - "administrator".to_owned() - } else { - format!("client-{index}") - }, - access_profile: format!("profile-{index}"), - scopes: vec!["casework:test".to_owned()], - claims: BTreeMap::new(), - }) - .collect(), - directory: Vec::new(), - integrations: None, - }; - state.clients = clients - .clients - .iter() - .enumerate() - .map(|(index, client)| ReportedClient { - id: client.id.clone(), - profile: client.access_profile.clone(), - role: if index == 0 { - CaseworkRole::Administrator - } else { - CaseworkRole::Requester - }, - principal: config::principal(&client.id), - }) - .collect(); - let mut issued = Vec::new(); - let terminate = AtomicBool::new(false); - - issue_tokens(&state, &clients, &terminate, |id| { - issued.push(id.to_owned()); - Ok(()) - }) - .unwrap(); - assert_eq!(issued.len(), clients.clients.len()); - assert_eq!(issued.last().map(String::as_str), Some("administrator")); + // A restart exports the pairs again instead of refusing the retained copies. + export_sources(®istry.executable, &mut state, &clients).unwrap(); + assert_eq!(registry.calls().len(), 2 * (1 + clients.clients.len())); assert_eq!( - issued.into_iter().collect::>(), - clients - .clients - .iter() - .map(|client| client.id.clone()) - .collect() + webhook, + fs::read_to_string(root.join("secrets/professional-register-webhook-key")).unwrap() ); } #[test] -fn token_issuance_stops_between_clients_when_interrupted() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let mut state = session(&project); - let clients = Clients { - version: 1, - clients: vec![ - config::Client { - id: "administrator".to_owned(), - access_profile: "administrator".to_owned(), - scopes: vec!["casework:admin".to_owned()], - claims: BTreeMap::new(), - }, - config::Client { - id: "requester".to_owned(), - access_profile: "requester".to_owned(), - scopes: vec!["casework:request".to_owned()], - claims: BTreeMap::new(), - }, - ], - directory: Vec::new(), - integrations: None, - }; - state.clients = vec![ - ReportedClient { - id: "administrator".to_owned(), - profile: "administrator".to_owned(), - role: CaseworkRole::Administrator, - principal: config::principal("administrator"), +fn incompatible_source_issuers_leave_retained_credentials_unchanged() { + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let registry = RegistrySession::new(); + let other_registry = registry.add_project("other-registry"); + RegistrySession::set_audience(&other_registry, "urn:breg:dev:other"); + let mut state = persisted_session(&project); + let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); + state.sources.insert( + "alpha".into(), + SourceSession { + project: registry.project.clone(), + binding: None, }, - ReportedClient { - id: "requester".to_owned(), - profile: "requester".to_owned(), - role: CaseworkRole::Requester, - principal: config::principal("requester"), + ); + state.sources.insert( + "beta".into(), + SourceSession { + project: other_registry, + binding: None, }, - ]; - let terminate = AtomicBool::new(false); - let mut issued = Vec::new(); + ); + state.save().unwrap(); + prepare_source_export_destinations(&state, &clients); + let canaries = retained_credential_canaries(&state, &clients); + let retained_state = fs::read(state.root().join("state.json")).unwrap(); let refusal = format!( "{:#}", - issue_tokens(&state, &clients, &terminate, |id| { - issued.push(id.to_owned()); - terminate.store(true, Ordering::Relaxed); - Ok(()) - }) - .unwrap_err() + export_sources(®istry.executable, &mut state, &clients).unwrap_err() ); - assert!(refusal.contains("interrupted"), "{refusal}"); - assert_eq!(issued, ["requester"]); -} - -#[test] -fn service_cleanup_joins_every_log_pump() { - let child = Command::new("/usr/bin/true") - .process_group(0) - .spawn() - .unwrap(); - let joined = Arc::new(AtomicBool::new(false)); - let marker = Arc::clone(&joined); - let pump = thread::spawn(move || { - thread::sleep(Duration::from_millis(20)); - marker.store(true, Ordering::Relaxed); - Ok(()) - }); - let mut children = Children { - casework: Some(Service::from_guard(child, vec![pump]).unwrap()), - }; - let deadline = Instant::now() + Duration::from_secs(2); - while !children.exited().unwrap() && Instant::now() < deadline { - thread::sleep(Duration::from_millis(5)); + assert!(refusal.contains("different local issuers"), "{refusal}"); + assert_eq!( + fs::read(state.root().join("state.json")).unwrap(), + retained_state + ); + for (path, bytes) in canaries { + assert_eq!(fs::read(path).unwrap(), bytes); } - assert!(children.exited().unwrap(), "guard did not become waitable"); - - children.stop().unwrap(); - assert!(joined.load(Ordering::Relaxed)); + assert!(state + .sources + .values() + .all(|source| source.binding.is_none())); } #[test] -fn legacy_issuer_state_and_unsafe_token_clients_are_refused_without_effects() { - let root = tempfile::tempdir().unwrap(); - let project = standalone(root.path()); - let state = session(&project); - private::directory(&project.join(".casework")).unwrap(); - private::directory(&state.root()).unwrap(); - let mut legacy = serde_json::to_value(&state).unwrap(); - legacy["version"] = json!(1); - let bytes = serde_json::to_vec(&legacy).unwrap(); - private::create(&state.root().join("state.json"), &bytes).unwrap(); - assert!(read_state(&state.root()) - .unwrap_err() - .to_string() - .contains("uses Mint")); - assert_eq!(fs::read(state.root().join("state.json")).unwrap(), bytes); - for id in ["../staff", "/staff", "", "staff/header"] { - assert!(fresh_token(&project, id) - .unwrap_err() - .to_string() - .contains("bounded local client")); +fn sources_need_the_same_shared_casework_client_credentials() { + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let registry = RegistrySession::new(); + let other_registry = registry.add_project("other-registry"); + RegistrySession::set_credentials(&other_registry, "other-key"); + let mut state = persisted_session(&project); + let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); + state.sources.insert( + "alpha".into(), + SourceSession { + project: registry.project.clone(), + binding: None, + }, + ); + state.sources.insert( + "beta".into(), + SourceSession { + project: other_registry, + binding: None, + }, + ); + state.save().unwrap(); + prepare_source_export_destinations(&state, &clients); + let canaries = retained_credential_canaries(&state, &clients); + let retained_state = fs::read(state.root().join("state.json")).unwrap(); + + let refusal = format!( + "{:#}", + export_sources(®istry.executable, &mut state, &clients).unwrap_err() + ); + + assert!(refusal.contains("sources alpha and beta"), "{refusal}"); + assert!( + refusal.contains("different credentials for Casework client"), + "{refusal}" + ); + assert_eq!( + fs::read(state.root().join("state.json")).unwrap(), + retained_state + ); + for (path, bytes) in canaries { + assert_eq!(fs::read(path).unwrap(), bytes); } + assert!(state + .sources + .values() + .all(|source| source.binding.is_none())); } #[test] -fn approved_grant_requires_explicit_connection_and_refuses_policy_fields() { - let args = [ - "caseworkctl", - "dev", - "grant", - "task-agent", - "--grant", - "01970000-0000-7000-8000-000000000001", - "--connection", - "/tmp/task-connection.yaml", - "/tmp/project", - ]; - assert!(::try_parse_from(args).is_ok()); - assert!(::try_parse_from([ - "caseworkctl", - "dev", - "grant", - "task-agent", - "--grant", - "01970000-0000-7000-8000-000000000001" - ]) - .is_err()); - let mut arbitrary = args.to_vec(); - arbitrary.extend(["--purpose", "invented"]); - assert!(::try_parse_from(arbitrary).is_err()); +fn two_sources_can_share_one_registry_client_registration() { + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let registry = RegistrySession::new(); + let mut state = persisted_session(&project); + let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); + for id in ["alpha", "beta"] { + state.sources.insert( + id.into(), + SourceSession { + project: registry.project.clone(), + binding: None, + }, + ); + } + state.save().unwrap(); + prepare_source_export_destinations(&state, &clients); + + export_sources(®istry.executable, &mut state, &clients).unwrap(); + + assert!(state + .sources + .values() + .all(|source| source.binding.is_some())); + for id in state.sources.keys() { + assert_eq!( + fs::read_to_string( + state + .root() + .join("secrets") + .join(format!("{id}-reader-client-id")) + ) + .unwrap(), + "casework-reader" + ); + } + for client in &clients.clients { + let directory = state.root().join("credentials").join(&client.id); + assert_eq!( + fs::read_to_string(directory.join("client-id")).unwrap(), + client.id + ); + assert_eq!( + fs::read_to_string(directory.join("assertion-key.jwk")).unwrap(), + r#"{"kty":"EC","fixture":"shared-key"}"# + ); + } + assert_eq!(registry.calls().len(), 2 * (1 + clients.clients.len())); + assert_eq!(read_state(&state.root()).unwrap().sources, state.sources); } #[test] -fn explicit_local_integrations_render_only_governed_authority_and_bind_the_source() { +fn active_source_revalidation_refuses_rotated_credentials_without_replacement() { let workspace = tempfile::tempdir().unwrap(); - let project = standalone(workspace.path()); - let mut policy = crate::project::load_and_check_policy(&project).unwrap(); - policy.sources.push(serde_json::from_value(json!({"id":"source","adapter":"breg","description":"source.json","requests":[{"entity":"correction","queue":"decisions"}]})).unwrap()); - let mut clients = config::clients(STANDALONE_DEV_CLIENTS.as_bytes()).unwrap(); - let integrations: integrations::Integrations = serde_json::from_value(json!({ - "resource":"urn:casework:source-group", - "sources":{"source":{"baseUrl":"http://127.0.0.1:8800","readerProfile":"reader", - "tokenEndpoint":"http://127.0.0.1:8093/oauth2/token","clientAssertionAudience":"http://127.0.0.1:8093", - "resource":"urn:casework:source-group","scopes":["records:get"], - "clientIdRef":"secret:file/service-reader-id","clientAssertionKeyRef":"secret:file/service-reader-key", - "webhookSecretRef":"secret:file/source-webhook","eventSource":"urn:registrystack:registry:source:instance:local"}}, - "serviceClients":[ - {"id":"reader","scopes":["records:get"]}, - {"id":"task-agent","scopes":["casework:grants:assert"],"taskExchange":true}, - {"id":"status","scopes":["casework:grants:status"]}], - "taskAuthority":{"id":"casework","issuer":"https://casework.local.example","jwksPort":8801,"statusClients":{"status":"urn:casework:source-group"}} - })).unwrap(); - clients.integrations = Some(integrations.clone()); - integrations.validate(&clients, &policy).unwrap(); - let mut state = session(&project); - state.resource = Some(integrations.resource.clone()); - integrations.validate_session(&state, &policy).unwrap(); - let root = project.join("private"); - private::directory(&root).unwrap(); - for name in ["issuer", "secrets", "credentials"] { - private::directory(&root.join(name)).unwrap(); - } - let key = config::keypair(&root.join("human")).unwrap(); - let mut description = registry_thunderid_tooling::local::local_description( - registry_thunderid_tooling::description::SessionIdentity { - label: "source-unit".into(), - id: "casework-local".into(), + let project = workspace.path().join("project"); + crate::project::init(&project, "professional-review").unwrap(); + let registry = RegistrySession::new(); + let mut state = persisted_session(&project); + let clients = config::clients(&fs::read(project.join("dev-clients.yaml")).unwrap()).unwrap(); + state.sources.insert( + "professional-register".into(), + SourceSession { + project: registry.project.clone(), + binding: None, }, - state.issuer_port, - root.join("issuer"), - state.audience(), - vec![registry_thunderid_tooling::local::LocalClient { - client_id: "staff".into(), - public_jwks: json!({"keys":[key]}).to_string(), - claims: BTreeMap::new(), - scopes: vec!["casework:staff".into()], - allow_human_fixture: true, - }], - ) - .unwrap(); - integrations - .prepare(&root, &state, &mut description, &policy) - .unwrap(); - let agent = description - .machine_clients - .iter() - .find(|client| client.client_id == "task-agent") - .unwrap(); - assert_eq!(agent.agent_id, config::principal("task-agent")); - assert!(agent.token_exchange.is_some()); - assert_eq!(agent.attributes["registry_actor_kind"], "agent"); - assert!(!agent - .attributes - .keys() - .any(|name| name.starts_with("registry_grant_"))); - let mut operator = config::operator(&state); - integrations - .operator(&state, &clients, &mut operator) - .unwrap(); - assert_eq!( - operator["taskAuthority"]["issuer"], - "https://casework.local.example" ); - assert_eq!(operator["sources"]["source"]["resource"], state.audience()); - assert_eq!(description.exchange_issuers.len(), 1); - let mut wrong = integrations.clone(); - wrong.sources.get_mut("source").unwrap().resource = Some("urn:other".into()); - assert!(wrong.validate_session(&state, &policy).is_err()); - let mut wrong = integrations.clone(); - wrong - .sources - .get_mut("source") - .unwrap() - .client_assertion_audience = Some("https://other.example".into()); - assert!(wrong.validate_session(&state, &policy).is_err()); - let mut wrong = integrations.clone(); - wrong.service_clients[1].scopes.push("records:get".into()); - assert!(wrong.validate(&clients, &policy).is_err()); - let mut wrong = integrations.clone(); - wrong.service_clients[0] - .claims - .insert("registry_actor_kind".into(), json!("human")); - assert!(wrong.validate(&clients, &policy).is_err()); - let mut wrong = integrations.clone(); - wrong - .sources - .get_mut("source") - .unwrap() - .client_assertion_key_ref = "secret:file/service-status-key".into(); - assert!(wrong.validate_session(&state, &policy).is_err()); - let mut wrong = integrations.clone(); - wrong.service_clients[0].scopes.push("records:patch".into()); - assert!(wrong.validate_session(&state, &policy).is_err()); - // Invalid binding input never installs a partially initialized session. - fs::write( - project.join("casework.yaml"), - serde_norway::to_string(&policy).unwrap(), + state.save().unwrap(); + prepare_source_export_destinations(&state, &clients); + private::create( + &state.root().join("clients.json"), + &serde_json::to_vec(&clients).unwrap(), ) .unwrap(); - fs::write(project.join("source.json"), serde_json::to_vec(&json!({ - "apiVersion":"registry.registrystack.org/casework-source-description/v1alpha1", - "kind":"BRegCaseworkSourceDescription","origin":"bregctl explain change-requests","authority":"none", - "sourceId":"source","sourceRevision":"sha256:source", - "request":{"requestEntity":"correction","requestRoute":"corrections","reviewMode":"staged", - "stages":[{"id":"review","approvals":1,"excludeSubmitter":true,"excludePreviousReviewers":false}], - "fields":[],"contractFingerprint":"sha256:contract","application":{"mode":"manual"}} - })).unwrap()).unwrap(); - let webhook = root.join("webhook-input"); - private::create(&webhook, b"synthetic-webhook-secret-at-least-32-bytes").unwrap(); - let mut staged_integrations = integrations.clone(); - staged_integrations - .secret_files - .insert("source-webhook".into(), webhook); - staged_integrations - .sources - .get_mut("source") - .unwrap() - .event_source = "urn:invalid:event".into(); - clients.integrations = Some(staged_integrations); - parent_directory(&project).unwrap(); - assert!(initialize(&state.root(), &state, &clients).is_err()); - assert!(!state.root().exists()); - clients - .integrations - .as_mut() - .unwrap() - .sources - .get_mut("source") - .unwrap() - .event_source = "urn:registrystack:registry:source:instance:local".into(); - initialize(&state.root(), &state, &clients).unwrap(); - assert!(state.root().join("operator.yaml").exists()); - let mut wrong = integrations; - wrong.sources.clear(); - assert!(wrong.validate(&clients, &policy).is_err()); + export_sources(®istry.executable, &mut state, &clients).unwrap(); + let mut retained = BTreeMap::new(); + for client in &clients.clients { + for name in ["client-id", "assertion-key.jwk"] { + let path = state.root().join("credentials").join(&client.id).join(name); + retained.insert(path.clone(), fs::read(path).unwrap()); + } + } + for suffix in ["reader-client-id", "reader-assertion-key.jwk"] { + let path = state + .root() + .join("secrets") + .join(format!("professional-register-{suffix}")); + retained.insert(path.clone(), fs::read(path).unwrap()); + } + + RegistrySession::set_credentials(®istry.project, "rotated-key"); + let refusal = require_active_source_bindings(®istry.executable, &state) + .unwrap_err() + .to_string(); + + assert!( + refusal.contains("earlier BREG reader registration"), + "{refusal}" + ); + for (path, bytes) in retained { + assert_eq!(fs::read(path).unwrap(), bytes); + } } diff --git a/crates/registry-caseworkctl/src/project.rs b/crates/registry-caseworkctl/src/project.rs index d8afd693ef..0bac47dc81 100644 --- a/crates/registry-caseworkctl/src/project.rs +++ b/crates/registry-caseworkctl/src/project.rs @@ -238,8 +238,8 @@ pub(super) const PROFESSIONAL_REVIEW_DEV_CLIENTS: &str = r#"# Local callers for # # This project binds a BReg source, so `caseworkctl dev` serves it only beside # a running `bregctl dev` session for that registry, named with -# `--source-project`: the local session borrows that registry's Mint as its -# issuer and exports each client below as a registry client with the same +# `--source-project`: the local session uses that registry's stock issuer +# and exports each client below as a registry client with the same # principal, which `caseworkctl source add --apply` writes into the registry's # own dev-clients.yaml. For a deployment, point these clients at the runtime's # own token issuer instead. diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 3a9e05adc0..5a3c978d08 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -25,15 +25,9 @@ const READER_CLIENT_ID: &str = "casework-reader"; const READER_PRINCIPAL_CLAIM: &str = "registry_principal"; const READER_SCOPE: &str = "casework:source-reader"; const READER_PURPOSE: &str = "casework-sync"; -/// The Mint claim a local BReg client's access token carries its purpose under. +/// The issuer claim a local BReg client's access token carries its purpose under. const PURPOSE_CLAIM: &str = "registry_purpose"; -/// A source-backed Casework session borrows BReg's local Mint, so a synthesized -/// subject must match the principal BReg registers for that client. -fn borrowed_breg_principal(client_id: &str) -> String { - format!("urn:breg:dev:{client_id}") -} - pub(super) fn run(args: &SourceAddArgs) -> Result { validate_id(&args.source_id)?; let registry = canonical_dir(&args.registry, "BReg project")?; @@ -667,15 +661,15 @@ fn reader_dev_client() -> Value { /// The scopes and purpose every Casework staff or supervisor dev client must /// carry to act as a reviewer on the selected BReg request: the union of /// `requiredScopes` from every distinct access profile named in its -/// `reviewGrants`/`applyGrants`, and the one `registry_purpose` those +/// `reviewPermissions`/`applyPermissions`, and the one `registry_purpose` those /// restricted profiles must accept in common. Profiles with no /// `requiredPurposes` restriction do not require the claim. fn reviewer_authority(authored: &Value, request: &Value) -> Result { - let profile_ids: BTreeSet<&str> = request["reviewGrants"] + let profile_ids: BTreeSet<&str> = request["reviewPermissions"] .as_array() .into_iter() .flatten() - .chain(request["applyGrants"].as_array().into_iter().flatten()) + .chain(request["applyPermissions"].as_array().into_iter().flatten()) .filter_map(|grant| grant["profile"].as_str()) .collect(); if profile_ids.is_empty() { @@ -796,7 +790,7 @@ fn human_dev_client( "a Casework staff or supervisor dev client has no reviewer authority to bind", )?; let principal = if casework_principal_claim == "sub" { - borrowed_breg_principal(id) + bail!("Casework dev client {id} uses principalClaim sub, whose stock-issuer subject is session-qualified; author an explicit stable principal claim for the shared BREG issuer bridge") } else { claims .get(casework_principal_claim) @@ -1456,8 +1450,8 @@ mod tests { }] }); let request = json!({ - "reviewGrants": [{"profile": "reviewer"}], - "applyGrants": [{"profile": "reviewer"}] + "reviewPermissions": [{"profile": "reviewer"}], + "applyPermissions": [{"profile": "reviewer"}] }); (authored, request) } @@ -1797,7 +1791,7 @@ mod tests { {"id":"reviewer","principalClaim":"sub","requiredScopes":["starter:reviewer"],"requiredPurposes":["starter-learning"]} ] }); - let request = json!({"reviewGrants":[{"profile":"reviewer"}],"applyGrants":[]}); + let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); assert!(reviewer_authority(&mismatched_principal, &request).is_err()); let disagreeing_purpose = json!({ @@ -1806,8 +1800,7 @@ mod tests { {"id":"approver","principalClaim":"registry_principal","requiredScopes":["starter:approver"],"requiredPurposes":["starter-approval"]} ] }); - let request = - json!({"reviewGrants":[{"profile":"reviewer"}],"applyGrants":[{"profile":"approver"}]}); + let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[{"profile":"approver"}]}); assert!(reviewer_authority(&disagreeing_purpose, &request).is_err()); } @@ -1819,8 +1812,7 @@ mod tests { {"id":"approver","principalClaim":"registry_principal","requiredScopes":[],"requiredPurposes":[]} ] }); - let request = - json!({"reviewGrants":[{"profile":"reviewer"}],"applyGrants":[{"profile":"approver"}]}); + let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[{"profile":"approver"}]}); let authority = reviewer_authority(&unrestricted, &request).unwrap(); assert!(authority.scopes.is_empty()); assert_eq!(authority.purpose, None); @@ -1847,7 +1839,7 @@ mod tests { }] }] }); - let request = json!({"reviewGrants":[{"profile":"reviewer"}],"applyGrants":[]}); + let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); let error = reviewer_authority(&authored, &request) .err() @@ -1916,7 +1908,7 @@ mod tests { } #[test] - fn human_dev_client_derives_the_mint_subject_for_breg_review() { + fn human_dev_client_refuses_a_session_qualified_subject_for_breg_review() { let authority = ReviewerAuthority { scopes: BTreeSet::from(["starter:reviewer".to_owned()]), purpose: None, @@ -1927,26 +1919,10 @@ mod tests { "claims":{"registry_actor_kind":"human"} }); - let merged = human_dev_client(&client, "staff", "sub", Some(&authority)).unwrap(); - assert_eq!( - merged["claims"][READER_PRINCIPAL_CLAIM], - borrowed_breg_principal("staff") - ); - assert!(merged["claims"].get("sub").is_none()); - - let already_aligned = json!({ - "id":"staff", - "scopes":["casework:staff"], - "claims":{ - "registry_actor_kind":"human", - "registry_principal":borrowed_breg_principal("staff") - } - }); - let merged = human_dev_client(&already_aligned, "staff", "sub", Some(&authority)).unwrap(); - assert_eq!( - merged["claims"][READER_PRINCIPAL_CLAIM], - borrowed_breg_principal("staff") - ); + let refusal = human_dev_client(&client, "staff", "sub", Some(&authority)) + .unwrap_err() + .to_string(); + assert!(refusal.contains("session-qualified"), "{refusal}"); } #[test] From 57b41ee43276e2a37c1ba6b2c348707dfd6235d6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 03:23:50 +0700 Subject: [PATCH 076/120] fix(dev): preserve source-backed teaching journey Signed-off-by: Jeremi Joslin --- crates/registry-bregctl/src/dev/config.rs | 13 ++++- crates/registry-bregctl/src/dev/examples.rs | 2 +- crates/registry-bregctl/src/dev/mod.rs | 16 ++++-- .../src/dev/prepare_source.rs | 1 + crates/registry-bregctl/src/dev/tests.rs | 54 +++++++++++++++++++ crates/registry-casework/src/runtime.rs | 3 ++ crates/registry-caseworkctl/src/dev/mod.rs | 4 +- crates/registry-caseworkctl/src/source_add.rs | 9 ++++ 8 files changed, 93 insertions(+), 9 deletions(-) diff --git a/crates/registry-bregctl/src/dev/config.rs b/crates/registry-bregctl/src/dev/config.rs index c120fbcd4f..ba1451ee67 100644 --- a/crates/registry-bregctl/src/dev/config.rs +++ b/crates/registry-bregctl/src/dev/config.rs @@ -36,6 +36,10 @@ pub(super) struct Client { /// claims for another product cannot call BReg accidentally. #[serde(default, skip_serializing_if = "is_false")] pub allow_breg_access: bool, + /// Explicitly permits this local teaching client to carry the human actor + /// marker. This does not admit the client to BReg's allowedClients. + #[serde(default, skip_serializing_if = "is_false")] + pub allow_human_fixture: bool, pub scopes: Vec, pub claims: BTreeMap, /// Exact schema-test steps that use this claim variant. Runtime requests @@ -100,6 +104,13 @@ pub(super) fn clients(bytes: &[u8]) -> Result { { bail!("local clients need unique bounded IDs and explicit scopes"); } + let carries_human_marker = client + .claims + .get("registry_actor_kind") + .is_some_and(|kind| kind == "human"); + if client.allow_human_fixture != carries_human_marker { + bail!("allowHumanFixture must be true exactly when registry_actor_kind is human"); + } for profile in &client.access_profiles { if !identifier(profile) || !client_profiles.insert(profile) { bail!( @@ -350,7 +361,7 @@ pub(super) fn issuer_description( public_jwks: serde_json::to_string(&json!({"keys":[public]}))?, claims, scopes: client.scopes.clone(), - allow_human_fixture: false, + allow_human_fixture: client.allow_human_fixture, }) }) .collect::>>()?; diff --git a/crates/registry-bregctl/src/dev/examples.rs b/crates/registry-bregctl/src/dev/examples.rs index 99513d6615..0e21aabec6 100644 --- a/crates/registry-bregctl/src/dev/examples.rs +++ b/crates/registry-bregctl/src/dev/examples.rs @@ -705,7 +705,7 @@ async fn execute( bail!("scenario client/profile is not explicitly declared in dev clients"); } if !native.contains_key(&step.client) { - super::token(state, &step.client)?; + super::token_async(state, &step.client).await?; let token = private::read( &state .root() diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index e70b7388b4..66b9f7d796 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -2195,6 +2195,14 @@ fn tokens(state: &State, clients: &Clients) -> Result<()> { /// assertion key never leaves the session's private credentials tree, and the /// credential is stored owner-only for the seeding and rehearsal steps. fn token(state: &State, id: &str) -> Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("cannot build the dev token runtime")?; + runtime.block_on(token_async(state, id)) +} + +async fn token_async(state: &State, id: &str) -> Result<()> { use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; let root = state.root(); @@ -2228,11 +2236,9 @@ fn token(state: &State, id: &str) -> Result<()> { .with_scopes(scopes), ) .map_err(|error| anyhow::anyhow!("the dev token provider is unusable: {error}"))?; - let value = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("cannot build the dev token runtime")? - .block_on(provider.bearer_token()) + let value = provider + .bearer_token() + .await .map_err(|error| anyhow::anyhow!("the dev issuer declined to issue a token: {error}"))?; let header = value.authorization_header_value(); let text = header diff --git a/crates/registry-bregctl/src/dev/prepare_source.rs b/crates/registry-bregctl/src/dev/prepare_source.rs index c12b511457..090434ebaf 100644 --- a/crates/registry-bregctl/src/dev/prepare_source.rs +++ b/crates/registry-bregctl/src/dev/prepare_source.rs @@ -342,6 +342,7 @@ pub(super) fn run(args: PrepareSourceArgs) -> Result { id: args.client.clone(), access_profiles: vec![args.access_profile.clone()], allow_breg_access: false, + allow_human_fixture: false, scopes: vec![scope], claims, test_bindings: Vec::new(), diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 88b86bffe6..67b4dbf528 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -218,6 +218,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { id: "guest".into(), access_profiles: vec![], allow_breg_access: false, + allow_human_fixture: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), test_bindings: Vec::new(), @@ -228,12 +229,24 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { id: "casework-reviewer".into(), access_profiles: vec![], allow_breg_access: true, + allow_human_fixture: false, scopes: vec!["registry:generic:review".into()], claims: BTreeMap::new(), test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, }); + clients.clients.push(config::Client { + id: "casework-administrator".into(), + access_profiles: vec![], + allow_breg_access: false, + allow_human_fixture: true, + scopes: vec!["casework:admin".into()], + claims: BTreeMap::from([("registry_actor_kind".into(), json!("human"))]), + test_bindings: Vec::new(), + client_id_file: None, + assertion_key_file: None, + }); initialize(&state.root(), &state, &clients, &files).unwrap(); let root = state.root(); let issuer = config::issuer_description(&state, &clients, &root).unwrap(); @@ -245,6 +258,10 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { .machine_clients .iter() .any(|client| client.client_id == "casework-reviewer")); + assert!(issuer + .machine_clients + .iter() + .any(|client| client.client_id == "casework-administrator")); let runtime: Value = serde_norway::from_slice( &private::read(&root.join("runtime-test.yaml"), MAX_BYTES).unwrap(), ) @@ -260,6 +277,10 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allowed.iter().any(|id| id == "casework-reviewer"), "the explicitly admitted integration client must be allowed: {allowed:?}" ); + assert!( + !allowed.iter().any(|id| id == "casework-administrator"), + "a teaching-human flag must not confer BREG access: {allowed:?}" + ); assert!( clients .clients @@ -270,6 +291,38 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { ); } +#[test] +fn human_teaching_clients_require_the_exact_explicit_fixture_flag() { + let without_flag = br#"version: 1 +clients: + - id: administrator + accessProfiles: [] + scopes: [casework:admin] + claims: {registry_actor_kind: human} +"#; + assert!(config::clients(without_flag).is_err()); + + let without_marker = br#"version: 1 +clients: + - id: administrator + accessProfiles: [] + allowHumanFixture: true + scopes: [casework:admin] + claims: {} +"#; + assert!(config::clients(without_marker).is_err()); + + let exact = br#"version: 1 +clients: + - id: administrator + accessProfiles: [] + allowHumanFixture: true + scopes: [casework:admin] + claims: {registry_actor_kind: human} +"#; + assert!(config::clients(exact).is_ok()); +} + /// A client with no bound access profile never interferes with rehearsal /// binding: every journey step still resolves to the client that actually /// binds its profile. @@ -282,6 +335,7 @@ fn rehearsal_binding_still_resolves_each_journey_step_despite_an_unbound_client( id: "guest".into(), access_profiles: vec![], allow_breg_access: false, + allow_human_fixture: false, scopes: vec!["registry:generic:introspect".into()], claims: BTreeMap::new(), test_bindings: Vec::new(), diff --git a/crates/registry-casework/src/runtime.rs b/crates/registry-casework/src/runtime.rs index 22346d4ef0..9df902b990 100644 --- a/crates/registry-casework/src/runtime.rs +++ b/crates/registry-casework/src/runtime.rs @@ -928,6 +928,9 @@ mod tests { base_url: "https://registry.example.test".into(), reader_profile: "casework-reader".into(), token_endpoint: "https://identity.example.test/token".into(), + client_assertion_audience: Some("https://identity.example.test".into()), + resource: Some("urn:example:registry".into()), + scopes: Some(vec!["casework:source-reader".into()]), client_id_ref: "secret:file/client-id".into(), client_assertion_key_ref: "secret:file/client-key".into(), webhook_secret_ref: "secret:file/webhook".into(), diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index dd6f2a10f6..1f97269fc2 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -101,7 +101,7 @@ enum DevAction { /// /// A resident supervisor owns this project's PostgreSQL container and /// Casework child. Standalone sessions also own a pinned issuer container; - /// --source-project sessions use the running BREG session's stock issuer. The database runs the pinned + /// --source-project sessions use the running BReg session's stock issuer. The database runs the pinned /// image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, /// which the supervisor pulls on the first start. Each supervised @@ -172,7 +172,7 @@ struct StartArgs { /// PostgreSQL loopback port on first start (default 55433; retained for restarts). #[arg(long, env = "CASEWORKCTL_DEV_DATABASE_PORT")] database_port: Option, - /// Running BREG dev project serving a declared source. This compatibility + /// Running BReg dev project serving a declared source. This compatibility /// bridge is available only without explicit integrations or task templates. #[arg(long, value_name = "[ID=]PATH")] source_project: Vec, diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 5a3c978d08..22ceaa2edf 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -835,6 +835,9 @@ fn human_dev_client( if allow_breg_access { result["allowBregAccess"] = json!(true); } + if result["claims"]["registry_actor_kind"] == "human" { + result["allowHumanFixture"] = json!(true); + } Ok(result) } @@ -1062,6 +1065,12 @@ fn render_dev_client_yaml_block(client: &Value) -> Result { .context("planned BReg dev client allowBregAccess must be a boolean")?; block.push_str(&format!(" allowBregAccess: {allow_breg_access}\n")); } + if let Some(allow_human_fixture) = client.get("allowHumanFixture") { + let allow_human_fixture = allow_human_fixture + .as_bool() + .context("planned BReg dev client allowHumanFixture must be a boolean")?; + block.push_str(&format!(" allowHumanFixture: {allow_human_fixture}\n")); + } if claims.is_empty() { block.push_str(" claims: {}\n"); } else { From 53b9b45b9535349ba75dd163211e50c5a0086e76 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 03:38:41 +0700 Subject: [PATCH 077/120] fix(casework): bind local reviewers to shared issuer Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/dev/mod.rs | 4 +- crates/registry-caseworkctl/src/source_add.rs | 175 +++++++++++++++++- 2 files changed, 176 insertions(+), 3 deletions(-) diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 1f97269fc2..e55fd8ca44 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -101,7 +101,7 @@ enum DevAction { /// /// A resident supervisor owns this project's PostgreSQL container and /// Casework child. Standalone sessions also own a pinned issuer container; - /// --source-project sessions use the running BReg session's stock issuer. The database runs the pinned + /// --source-project sessions use the running Base Registry Engine (BReg) session's stock issuer. The database runs the pinned /// image /// postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675, /// which the supervisor pulls on the first start. Each supervised @@ -172,7 +172,7 @@ struct StartArgs { /// PostgreSQL loopback port on first start (default 55433; retained for restarts). #[arg(long, env = "CASEWORKCTL_DEV_DATABASE_PORT")] database_port: Option, - /// Running BReg dev project serving a declared source. This compatibility + /// Running Base Registry Engine (BReg) dev project serving a declared source. This compatibility /// bridge is available only without explicit integrations or task templates. #[arg(long, value_name = "[ID=]PATH")] source_project: Vec, diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 22ceaa2edf..3a39dd18a1 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -52,7 +52,15 @@ pub(super) fn run(args: &SourceAddArgs) -> Result { } let mut authored: Value = serde_norway::from_slice(&bytes) .context("parsing BReg registry.yaml without duplicate or custom YAML values")?; - let changes = apply_breg_candidate(&mut authored, &request_entity, &projection)?; + let mut changes = apply_breg_candidate(&mut authored, &request_entity, &projection)?; + let reviewer_clients = local_reviewer_clients(&project)?; + if let Value::Array(existing) = &mut changes { + existing.extend(bind_local_reviewers( + &mut authored, + request.0, + &reviewer_clients, + )?); + } let proposed = render_candidate_preserving_authored_text(&bytes, &request_entity, &authored, &projection)?; let candidate_explanation = verify_candidate(&args.bregctl_bin, ®istry, &proposed)?; @@ -498,6 +506,86 @@ fn candidate_fragments(entity_id: &str, projection: &[String]) -> (Value, Value) ) } +fn local_reviewer_clients(project: &Path) -> Result> { + let bytes = match fs::read(project.join("dev-clients.yaml")) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error).context("reading Casework dev clients"), + }; + let clients: Value = + serde_norway::from_slice(&bytes).context("parsing Casework dev clients")?; + let policy = load_casework_policy(project)?; + let profiles = policy["accessProfiles"] + .as_array() + .context("casework.yaml has no accessProfiles")?; + let mut result = Vec::new(); + for client in clients["clients"] + .as_array() + .context("dev-clients.yaml has no clients")? + { + let profile_id = client["accessProfile"] + .as_str() + .context("a Casework dev client has no accessProfile")?; + let profile = profiles + .iter() + .find(|profile| profile["id"] == profile_id) + .context("a Casework dev client names an unknown access profile")?; + if matches!(profile["role"].as_str(), Some("staff" | "supervisor")) { + result.push( + client["id"] + .as_str() + .context("a Casework dev client has no id")? + .to_owned(), + ); + } + } + result.sort(); + result.dedup(); + Ok(result) +} + +fn bind_local_reviewers( + authored: &mut Value, + request: &Value, + clients: &[String], +) -> Result> { + if clients.is_empty() { + return Ok(Vec::new()); + } + let profile_ids = request["reviewPermissions"] + .as_array() + .into_iter() + .flatten() + .chain(request["applyPermissions"].as_array().into_iter().flatten()) + .filter_map(|permission| permission["profile"].as_str()) + .collect::>(); + let profiles = authored["accessProfiles"] + .as_array_mut() + .context("BReg registry.yaml has no accessProfiles")?; + let expected_clients = serde_json::to_value(clients)?; + let mut changes = Vec::new(); + for id in profile_ids { + let profile = profiles + .iter_mut() + .find(|profile| profile["id"] == id) + .with_context(|| format!("BReg reviewer profile {id} is absent"))?; + let actor = profile.get("actorKind"); + let requesters = profile.get("requesterClients"); + match (actor, requesters) { + (None, None) | (Some(Value::Null), Some(Value::Null)) => { + profile["actorKind"] = json!("human"); + profile["requesterClients"] = expected_clients.clone(); + changes.push(json!({"file":"registry.yaml","path":format!("/accessProfiles/{id}/actorKind"),"operation":"ensure_exact"})); + changes.push(json!({"file":"registry.yaml","path":format!("/accessProfiles/{id}/requesterClients"),"operation":"ensure_exact"})); + } + (Some(actor), Some(requesters)) + if actor == "human" && requesters == &expected_clients => {} + _ => bail!("BReg reviewer profile {id} has an incompatible actor/client binding"), + } + } + Ok(changes) +} + fn render_candidate_preserving_authored_text( original: &[u8], entity_id: &str, @@ -535,6 +623,31 @@ fn render_candidate_preserving_authored_text( if !has_profile { rendered = insert_access_profile(&rendered, entity_id, projection)?; } + for profile in expected["accessProfiles"].as_array().into_iter().flatten() { + if profile["actorKind"] == "human" { + let id = profile["id"] + .as_str() + .context("bound BReg profile has no id")?; + let requester_clients = profile["requesterClients"] + .as_array() + .context("bound BReg profile has no requesterClients")? + .iter() + .map(|client| { + client + .as_str() + .map(str::to_owned) + .context("bound BReg requester client is not a string") + }) + .collect::>>()?; + let current: Value = serde_norway::from_str(&rendered)?; + let current_profile = current["accessProfiles"] + .as_array() + .and_then(|profiles| profiles.iter().find(|profile| profile["id"] == id)); + if current_profile.is_some_and(|profile| profile.get("actorKind").is_none()) { + rendered = insert_profile_actor_binding(&rendered, id, &requester_clients)?; + } + } + } let round_trip: Value = serde_norway::from_str(&rendered).context("parsing narrow BReg YAML patch")?; if &round_trip != expected { @@ -543,6 +656,27 @@ fn render_candidate_preserving_authored_text( Ok(rendered) } +fn insert_profile_actor_binding( + text: &str, + profile_id: &str, + clients: &[String], +) -> Result { + let lines = text.split_inclusive('\n').collect::>(); + let access_profiles = lines + .iter() + .position(|line| { + leading_spaces(line) == 0 && line.trim_start().starts_with("accessProfiles:") + }) + .context("narrow YAML patch could not locate accessProfiles")?; + let marker = format!("- id: {profile_id}"); + let profile = (access_profiles + 1..lines.len()) + .find(|index| leading_spaces(lines[*index]) == 2 && lines[*index].trim() == marker) + .with_context(|| format!("narrow YAML patch could not locate BReg profile {profile_id}"))?; + let clients = clients.join(", "); + let block = format!(" actorKind: human\n requesterClients: [{clients}]\n"); + Ok(insert_at_line(&lines, profile + 1, &block)) +} + fn insert_entity_event(text: &str, entity_id: &str) -> Result { let lines = text.split_inclusive('\n').collect::>(); let marker = format!("- id: {entity_id}"); @@ -1465,6 +1599,45 @@ mod tests { (authored, request) } + #[test] + fn local_reviewers_bind_the_selected_profiles_to_exact_human_clients() { + let (mut authored, request) = reviewer_fixture(); + let clients = vec!["staff".to_owned(), "supervisor".to_owned()]; + let changes = bind_local_reviewers(&mut authored, &request, &clients).unwrap(); + let reviewer = &authored["accessProfiles"][0]; + assert_eq!(reviewer["actorKind"], "human"); + assert_eq!(reviewer["requesterClients"], json!(clients)); + assert_eq!(changes.len(), 2); + assert!(bind_local_reviewers(&mut authored, &request, &clients) + .unwrap() + .is_empty()); + + authored["accessProfiles"][0]["requesterClients"] = json!(["another-client"]); + assert!(bind_local_reviewers(&mut authored, &request, &clients).is_err()); + } + + #[test] + fn narrow_yaml_patch_preserves_comments_while_binding_reviewers() { + let input = "# registry\naccessProfiles:\n - id: reviewer\n # retained reviewer scope\n principalClaim: registry_principal\n requiredScopes: [starter:reviewer]\n requiredPurposes: [starter-learning]\nentities:\n - id: request\n route: requests\n"; + let mut expected: Value = serde_norway::from_str(input).unwrap(); + let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); + bind_local_reviewers( + &mut expected, + &request, + &["staff".into(), "supervisor".into()], + ) + .unwrap(); + apply_breg_candidate(&mut expected, "request", &[]).unwrap(); + let rendered = + render_candidate_preserving_authored_text(input.as_bytes(), "request", &expected, &[]) + .unwrap(); + assert!(rendered.contains("# retained reviewer scope")); + assert_eq!( + serde_norway::from_str::(&rendered).unwrap(), + expected + ); + } + #[test] fn dev_clients_plan_adds_reader_and_eligible_casework_clients_json_authored() { let root = tempfile::tempdir().unwrap(); From 845410835f3d0da9721eceedd76b29942f788b50 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 03:52:42 +0700 Subject: [PATCH 078/120] fix(casework): preserve human reviewer admission Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/source_add.rs | 275 ++++++------------ .../core/dev-clients.yaml | 35 ++- .../core/examples/scenarios.json | 10 +- .../professional-licences/core/registry.yaml | 5 + .../core/tests/journeys.yaml | 24 +- .../core/tests/security-journeys.yaml | 28 +- 6 files changed, 172 insertions(+), 205 deletions(-) diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 3a39dd18a1..7a15555254 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -52,15 +52,7 @@ pub(super) fn run(args: &SourceAddArgs) -> Result { } let mut authored: Value = serde_norway::from_slice(&bytes) .context("parsing BReg registry.yaml without duplicate or custom YAML values")?; - let mut changes = apply_breg_candidate(&mut authored, &request_entity, &projection)?; - let reviewer_clients = local_reviewer_clients(&project)?; - if let Value::Array(existing) = &mut changes { - existing.extend(bind_local_reviewers( - &mut authored, - request.0, - &reviewer_clients, - )?); - } + let changes = apply_breg_candidate(&mut authored, &request_entity, &projection)?; let proposed = render_candidate_preserving_authored_text(&bytes, &request_entity, &authored, &projection)?; let candidate_explanation = verify_candidate(&args.bregctl_bin, ®istry, &proposed)?; @@ -298,6 +290,7 @@ fn require_ok(operation: &str, report: &Value) -> Result<()> { /// The scopes and purpose a Casework staff or supervisor dev client inherits /// from the selected BReg request's review and apply access profiles. struct ReviewerAuthority { + profiles: BTreeSet, scopes: BTreeSet, purpose: Option, } @@ -506,86 +499,6 @@ fn candidate_fragments(entity_id: &str, projection: &[String]) -> (Value, Value) ) } -fn local_reviewer_clients(project: &Path) -> Result> { - let bytes = match fs::read(project.join("dev-clients.yaml")) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => return Err(error).context("reading Casework dev clients"), - }; - let clients: Value = - serde_norway::from_slice(&bytes).context("parsing Casework dev clients")?; - let policy = load_casework_policy(project)?; - let profiles = policy["accessProfiles"] - .as_array() - .context("casework.yaml has no accessProfiles")?; - let mut result = Vec::new(); - for client in clients["clients"] - .as_array() - .context("dev-clients.yaml has no clients")? - { - let profile_id = client["accessProfile"] - .as_str() - .context("a Casework dev client has no accessProfile")?; - let profile = profiles - .iter() - .find(|profile| profile["id"] == profile_id) - .context("a Casework dev client names an unknown access profile")?; - if matches!(profile["role"].as_str(), Some("staff" | "supervisor")) { - result.push( - client["id"] - .as_str() - .context("a Casework dev client has no id")? - .to_owned(), - ); - } - } - result.sort(); - result.dedup(); - Ok(result) -} - -fn bind_local_reviewers( - authored: &mut Value, - request: &Value, - clients: &[String], -) -> Result> { - if clients.is_empty() { - return Ok(Vec::new()); - } - let profile_ids = request["reviewPermissions"] - .as_array() - .into_iter() - .flatten() - .chain(request["applyPermissions"].as_array().into_iter().flatten()) - .filter_map(|permission| permission["profile"].as_str()) - .collect::>(); - let profiles = authored["accessProfiles"] - .as_array_mut() - .context("BReg registry.yaml has no accessProfiles")?; - let expected_clients = serde_json::to_value(clients)?; - let mut changes = Vec::new(); - for id in profile_ids { - let profile = profiles - .iter_mut() - .find(|profile| profile["id"] == id) - .with_context(|| format!("BReg reviewer profile {id} is absent"))?; - let actor = profile.get("actorKind"); - let requesters = profile.get("requesterClients"); - match (actor, requesters) { - (None, None) | (Some(Value::Null), Some(Value::Null)) => { - profile["actorKind"] = json!("human"); - profile["requesterClients"] = expected_clients.clone(); - changes.push(json!({"file":"registry.yaml","path":format!("/accessProfiles/{id}/actorKind"),"operation":"ensure_exact"})); - changes.push(json!({"file":"registry.yaml","path":format!("/accessProfiles/{id}/requesterClients"),"operation":"ensure_exact"})); - } - (Some(actor), Some(requesters)) - if actor == "human" && requesters == &expected_clients => {} - _ => bail!("BReg reviewer profile {id} has an incompatible actor/client binding"), - } - } - Ok(changes) -} - fn render_candidate_preserving_authored_text( original: &[u8], entity_id: &str, @@ -623,31 +536,6 @@ fn render_candidate_preserving_authored_text( if !has_profile { rendered = insert_access_profile(&rendered, entity_id, projection)?; } - for profile in expected["accessProfiles"].as_array().into_iter().flatten() { - if profile["actorKind"] == "human" { - let id = profile["id"] - .as_str() - .context("bound BReg profile has no id")?; - let requester_clients = profile["requesterClients"] - .as_array() - .context("bound BReg profile has no requesterClients")? - .iter() - .map(|client| { - client - .as_str() - .map(str::to_owned) - .context("bound BReg requester client is not a string") - }) - .collect::>>()?; - let current: Value = serde_norway::from_str(&rendered)?; - let current_profile = current["accessProfiles"] - .as_array() - .and_then(|profiles| profiles.iter().find(|profile| profile["id"] == id)); - if current_profile.is_some_and(|profile| profile.get("actorKind").is_none()) { - rendered = insert_profile_actor_binding(&rendered, id, &requester_clients)?; - } - } - } let round_trip: Value = serde_norway::from_str(&rendered).context("parsing narrow BReg YAML patch")?; if &round_trip != expected { @@ -656,27 +544,6 @@ fn render_candidate_preserving_authored_text( Ok(rendered) } -fn insert_profile_actor_binding( - text: &str, - profile_id: &str, - clients: &[String], -) -> Result { - let lines = text.split_inclusive('\n').collect::>(); - let access_profiles = lines - .iter() - .position(|line| { - leading_spaces(line) == 0 && line.trim_start().starts_with("accessProfiles:") - }) - .context("narrow YAML patch could not locate accessProfiles")?; - let marker = format!("- id: {profile_id}"); - let profile = (access_profiles + 1..lines.len()) - .find(|index| leading_spaces(lines[*index]) == 2 && lines[*index].trim() == marker) - .with_context(|| format!("narrow YAML patch could not locate BReg profile {profile_id}"))?; - let clients = clients.join(", "); - let block = format!(" actorKind: human\n requesterClients: [{clients}]\n"); - Ok(insert_at_line(&lines, profile + 1, &block)) -} - fn insert_entity_event(text: &str, entity_id: &str) -> Result { let lines = text.split_inclusive('\n').collect::>(); let marker = format!("- id: {entity_id}"); @@ -798,7 +665,11 @@ fn reader_dev_client() -> Value { /// `reviewPermissions`/`applyPermissions`, and the one `registry_purpose` those /// restricted profiles must accept in common. Profiles with no /// `requiredPurposes` restriction do not require the claim. -fn reviewer_authority(authored: &Value, request: &Value) -> Result { +fn reviewer_authority( + authored: &Value, + request: &Value, + reviewer_clients: &BTreeSet, +) -> Result { let profile_ids: BTreeSet<&str> = request["reviewPermissions"] .as_array() .into_iter() @@ -814,14 +685,30 @@ fn reviewer_authority(authored: &Value, request: &Value) -> Result> = None; - for id in profile_ids { + for id in &profile_ids { let profile = profiles .iter() - .find(|candidate| candidate["id"] == id) + .find(|candidate| candidate["id"] == *id) .with_context(|| format!("BReg access profile {id} named by the selected request is absent from registry.yaml"))?; if profile["principalClaim"] != Value::String(READER_PRINCIPAL_CLAIM.to_owned()) { bail!("BReg access profile {id} does not authenticate its principal through {READER_PRINCIPAL_CLAIM}"); } + if profile["actorKind"] != "human" { + bail!("BReg access profile {id} must declare actorKind human for Casework reviewers"); + } + let requesters = profile["requesterClients"] + .as_array() + .with_context(|| format!("BReg access profile {id} must declare requesterClients"))? + .iter() + .map(|client| { + client.as_str().map(str::to_owned).with_context(|| { + format!("BReg access profile {id} requesterClients must be strings") + }) + }) + .collect::>>()?; + if &requesters != reviewer_clients { + bail!("BReg access profile {id} requesterClients must exactly match the Casework staff and supervisor clients"); + } if has_nonempty_row_boundaries(profile) { bail!( "BReg access profile {id} uses rowBoundaries, which local Casework reviewer client export does not support" @@ -871,7 +758,11 @@ fn reviewer_authority(authored: &Value, request: &Value) -> Result bool { @@ -960,9 +851,16 @@ fn human_dev_client( "Casework dev client {id} exceeds BReg local client scope or claim bounds after reviewer authority is added" ); } + let access_profiles = if role == "supervisor" { + authority + .map(|authority| authority.profiles.iter().cloned().collect::>()) + .unwrap_or_default() + } else { + Vec::new() + }; let mut result = json!({ "id": id, - "accessProfiles": Vec::::new(), + "accessProfiles": access_profiles, "scopes": scopes.into_iter().collect::>(), "claims": claims, }); @@ -1015,6 +913,7 @@ fn plan_breg_dev_clients( let mut eligible = Vec::new(); let mut needs_authority = false; + let mut reviewer_clients = BTreeSet::new(); for client in casework_clients { let profile_id = client["accessProfile"] .as_str() @@ -1029,11 +928,19 @@ fn plan_breg_dev_clients( let principal_claim = profile["principalClaim"] .as_str() .context("a Casework access profile's principalClaim must be a string")?; - needs_authority |= matches!(role, "staff" | "supervisor"); + if matches!(role, "staff" | "supervisor") { + needs_authority = true; + reviewer_clients.insert( + client["id"] + .as_str() + .context("a Casework dev client's id must be a string")? + .to_owned(), + ); + } eligible.push((client, role.to_owned(), principal_claim.to_owned())); } let authority = if needs_authority { - Some(reviewer_authority(authored, request)?) + Some(reviewer_authority(authored, request, &reviewer_clients)?) } else { None }; @@ -1588,6 +1495,8 @@ mod tests { "accessProfiles": [{ "id": "reviewer", "principalClaim": "registry_principal", + "actorKind": "human", + "requesterClients": ["staff", "supervisor"], "requiredScopes": ["starter:reviewer"], "requiredPurposes": ["starter-learning"] }] @@ -1599,43 +1508,10 @@ mod tests { (authored, request) } - #[test] - fn local_reviewers_bind_the_selected_profiles_to_exact_human_clients() { - let (mut authored, request) = reviewer_fixture(); - let clients = vec!["staff".to_owned(), "supervisor".to_owned()]; - let changes = bind_local_reviewers(&mut authored, &request, &clients).unwrap(); - let reviewer = &authored["accessProfiles"][0]; - assert_eq!(reviewer["actorKind"], "human"); - assert_eq!(reviewer["requesterClients"], json!(clients)); - assert_eq!(changes.len(), 2); - assert!(bind_local_reviewers(&mut authored, &request, &clients) - .unwrap() - .is_empty()); - - authored["accessProfiles"][0]["requesterClients"] = json!(["another-client"]); - assert!(bind_local_reviewers(&mut authored, &request, &clients).is_err()); - } - - #[test] - fn narrow_yaml_patch_preserves_comments_while_binding_reviewers() { - let input = "# registry\naccessProfiles:\n - id: reviewer\n # retained reviewer scope\n principalClaim: registry_principal\n requiredScopes: [starter:reviewer]\n requiredPurposes: [starter-learning]\nentities:\n - id: request\n route: requests\n"; - let mut expected: Value = serde_norway::from_str(input).unwrap(); - let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); - bind_local_reviewers( - &mut expected, - &request, - &["staff".into(), "supervisor".into()], - ) - .unwrap(); - apply_breg_candidate(&mut expected, "request", &[]).unwrap(); - let rendered = - render_candidate_preserving_authored_text(input.as_bytes(), "request", &expected, &[]) - .unwrap(); - assert!(rendered.contains("# retained reviewer scope")); - assert_eq!( - serde_norway::from_str::(&rendered).unwrap(), - expected - ); + fn reviewer_clients() -> BTreeSet { + ["staff".to_owned(), "supervisor".to_owned()] + .into_iter() + .collect() } #[test] @@ -1688,6 +1564,14 @@ mod tests { for role in ["supervisor", "staff"] { let client = clients.iter().find(|c| c["id"] == role).unwrap(); assert_eq!(client["allowBregAccess"], true); + assert_eq!( + client["accessProfiles"], + if role == "supervisor" { + json!(["reviewer"]) + } else { + json!([]) + } + ); assert_eq!( client["scopes"], json!([format!("casework:{role}"), "starter:reviewer"]) @@ -1974,7 +1858,7 @@ mod tests { ] }); let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); - assert!(reviewer_authority(&mismatched_principal, &request).is_err()); + assert!(reviewer_authority(&mismatched_principal, &request, &reviewer_clients()).is_err()); let disagreeing_purpose = json!({ "accessProfiles": [ @@ -1983,19 +1867,33 @@ mod tests { ] }); let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[{"profile":"approver"}]}); - assert!(reviewer_authority(&disagreeing_purpose, &request).is_err()); + assert!(reviewer_authority(&disagreeing_purpose, &request, &reviewer_clients()).is_err()); + } + + #[test] + fn reviewer_authority_requires_exact_human_casework_clients() { + let (mut authored, request) = reviewer_fixture(); + authored["accessProfiles"][0] + .as_object_mut() + .unwrap() + .remove("actorKind"); + assert!(reviewer_authority(&authored, &request, &reviewer_clients()).is_err()); + + let (mut authored, request) = reviewer_fixture(); + authored["accessProfiles"][0]["requesterClients"] = json!(["supervisor"]); + assert!(reviewer_authority(&authored, &request, &reviewer_clients()).is_err()); } #[test] fn reviewer_authority_accepts_unrestricted_purpose_profiles() { let unrestricted = json!({ "accessProfiles": [ - {"id":"reviewer","principalClaim":"registry_principal"}, - {"id":"approver","principalClaim":"registry_principal","requiredScopes":[],"requiredPurposes":[]} + {"id":"reviewer","principalClaim":"registry_principal","actorKind":"human","requesterClients":["staff","supervisor"]}, + {"id":"approver","principalClaim":"registry_principal","actorKind":"human","requesterClients":["staff","supervisor"],"requiredScopes":[],"requiredPurposes":[]} ] }); let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[{"profile":"approver"}]}); - let authority = reviewer_authority(&unrestricted, &request).unwrap(); + let authority = reviewer_authority(&unrestricted, &request, &reviewer_clients()).unwrap(); assert!(authority.scopes.is_empty()); assert_eq!(authority.purpose, None); @@ -2015,6 +1913,8 @@ mod tests { "accessProfiles": [{ "id":"reviewer", "principalClaim":"registry_principal", + "actorKind":"human", + "requesterClients":["staff","supervisor"], "permissions":[{ "entity":"request", "rowBoundaries":[{"field":"region","claim":"allowed_regions","operator":"in"}] @@ -2023,7 +1923,7 @@ mod tests { }); let request = json!({"reviewPermissions":[{"profile":"reviewer"}],"applyPermissions":[]}); - let error = reviewer_authority(&authored, &request) + let error = reviewer_authority(&authored, &request, &reviewer_clients()) .err() .expect("row-boundary authority must be refused"); let message = format!("{error:#}"); @@ -2035,6 +1935,7 @@ mod tests { #[test] fn human_dev_client_bails_when_existing_purpose_claim_conflicts() { let authority = ReviewerAuthority { + profiles: BTreeSet::from(["reviewer".to_owned()]), scopes: BTreeSet::from(["starter:reviewer".to_owned()]), purpose: Some("starter-learning".to_owned()), }; @@ -2063,6 +1964,7 @@ mod tests { #[test] fn human_dev_client_maps_the_configured_casework_principal_for_breg_review() { let authority = ReviewerAuthority { + profiles: BTreeSet::from(["reviewer".to_owned()]), scopes: BTreeSet::from(["starter:reviewer".to_owned()]), purpose: None, }; @@ -2092,6 +1994,7 @@ mod tests { #[test] fn human_dev_client_refuses_a_session_qualified_subject_for_breg_review() { let authority = ReviewerAuthority { + profiles: BTreeSet::from(["reviewer".to_owned()]), scopes: BTreeSet::from(["starter:reviewer".to_owned()]), purpose: None, }; @@ -2110,6 +2013,7 @@ mod tests { #[test] fn human_dev_client_refuses_authority_over_breg_scope_or_claim_bounds() { let authority = ReviewerAuthority { + profiles: BTreeSet::from(["reviewer".to_owned()]), scopes: BTreeSet::from(["starter:reviewer".to_owned()]), purpose: None, }; @@ -2145,6 +2049,7 @@ mod tests { "supervisor", "employee_id", Some(&ReviewerAuthority { + profiles: BTreeSet::from(["reviewer".to_owned()]), scopes: BTreeSet::new(), purpose: None, }), diff --git a/products/breg/starters/professional-licences/core/dev-clients.yaml b/products/breg/starters/professional-licences/core/dev-clients.yaml index 95f061fff4..b9d22faa76 100644 --- a/products/breg/starters/professional-licences/core/dev-clients.yaml +++ b/products/breg/starters/professional-licences/core/dev-clients.yaml @@ -28,15 +28,46 @@ } }, { - "id": "reviewer", + "id": "administrator", + "accessProfiles": [], + "allowHumanFixture": true, + "scopes": [ + "casework:admin" + ], + "claims": { + "registry_actor_kind": "human", + "registry_principal": "professional-review-administrator" + } + }, + { + "id": "supervisor", "accessProfiles": [ "reviewer" ], + "allowBregAccess": true, + "allowHumanFixture": true, + "scopes": [ + "casework:supervisor", + "starter:reviewer" + ], + "claims": { + "registry_actor_kind": "human", + "registry_principal": "professional-review-supervisor", + "registry_purpose": "starter-learning" + } + }, + { + "id": "staff", + "accessProfiles": [], + "allowBregAccess": true, + "allowHumanFixture": true, "scopes": [ + "casework:staff", "starter:reviewer" ], "claims": { - "registry_principal": "synthetic-reviewer", + "registry_actor_kind": "human", + "registry_principal": "professional-review-staff", "registry_purpose": "starter-learning" } } diff --git a/products/breg/starters/professional-licences/core/examples/scenarios.json b/products/breg/starters/professional-licences/core/examples/scenarios.json index 6ba32a59b2..576c54d84e 100644 --- a/products/breg/starters/professional-licences/core/examples/scenarios.json +++ b/products/breg/starters/professional-licences/core/examples/scenarios.json @@ -85,7 +85,7 @@ "id": "inspect", "operation": "get", "entity": "scope-correction", - "client": "reviewer", + "client": "supervisor", "accessProfile": "reviewer", "record": "request" }, @@ -93,7 +93,7 @@ "id": "approve", "operation": "approve", "entity": "scope-correction", - "client": "reviewer", + "client": "supervisor", "accessProfile": "reviewer", "record": "request" }, @@ -101,7 +101,7 @@ "id": "reject", "operation": "reject", "entity": "scope-correction", - "client": "reviewer", + "client": "supervisor", "accessProfile": "reviewer", "record": "request" }, @@ -109,7 +109,7 @@ "id": "apply", "operation": "apply", "entity": "scope-correction", - "client": "reviewer", + "client": "supervisor", "accessProfile": "reviewer", "record": "request" }, @@ -117,7 +117,7 @@ "id": "history", "operation": "history", "entity": "professional-license", - "client": "reviewer", + "client": "supervisor", "accessProfile": "reviewer", "record": "first-record" } diff --git a/products/breg/starters/professional-licences/core/registry.yaml b/products/breg/starters/professional-licences/core/registry.yaml index fe4074963a..da4aa04545 100644 --- a/products/breg/starters/professional-licences/core/registry.yaml +++ b/products/breg/starters/professional-licences/core/registry.yaml @@ -533,6 +533,11 @@ } ] } + ], + "actorKind": "human", + "requesterClients": [ + "staff", + "supervisor" ] }, { diff --git a/products/breg/starters/professional-licences/core/tests/journeys.yaml b/products/breg/starters/professional-licences/core/tests/journeys.yaml index a94d76f602..188b899828 100644 --- a/products/breg/starters/professional-licences/core/tests/journeys.yaml +++ b/products/breg/starters/professional-licences/core/tests/journeys.yaml @@ -121,7 +121,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -142,7 +144,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "approve_request", @@ -201,7 +205,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -222,7 +228,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "apply_request", @@ -354,7 +362,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -375,7 +385,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "reject_request", diff --git a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml index 2fbc75f5b0..c965baf638 100644 --- a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml +++ b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml @@ -190,7 +190,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -211,7 +213,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "approve_request", @@ -236,7 +240,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "approve_request", @@ -295,7 +301,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -316,7 +324,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "apply_request", @@ -447,7 +457,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "get", @@ -468,7 +480,9 @@ "scopes": [ "starter:reviewer" ], - "purpose": "starter-learning" + "purpose": "starter-learning", + "actorKind": "human", + "requesterClient": "supervisor" }, "request": { "operation": "reject_request", From e403c718e85b9340bad77a62ea832e7ba758e7ea Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 03:54:20 +0700 Subject: [PATCH 079/120] fix(breg): bind reviewer fixtures to supervisor Signed-off-by: Jeremi Joslin --- .../professional-licences/core/tests/journeys.yaml | 12 ++++++------ .../core/tests/security-journeys.yaml | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/products/breg/starters/professional-licences/core/tests/journeys.yaml b/products/breg/starters/professional-licences/core/tests/journeys.yaml index 188b899828..2eb64f3f81 100644 --- a/products/breg/starters/professional-licences/core/tests/journeys.yaml +++ b/products/breg/starters/professional-licences/core/tests/journeys.yaml @@ -117,7 +117,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -140,7 +140,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -201,7 +201,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -224,7 +224,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -358,7 +358,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -381,7 +381,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], diff --git a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml index c965baf638..eeab0b37d0 100644 --- a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml +++ b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml @@ -186,7 +186,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -209,7 +209,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-editor", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -236,7 +236,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -297,7 +297,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -320,7 +320,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -453,7 +453,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], @@ -476,7 +476,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "synthetic-reviewer", + "principal": "professional-review-supervisor", "scopes": [ "starter:reviewer" ], From 862465fa7d00c55f3f183042590771bb2a2b7d42 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:03:37 +0700 Subject: [PATCH 080/120] docs(casework): adopt stock issuer workflow Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-casework-tutorial.sh | 20 ++- .../scripts/check-casework-tutorial.test.mjs | 20 ++- docs/site/src/content/docs/changelog.mdx | 15 +-- .../src/content/docs/configure/casework.mdx | 11 +- .../content/docs/tutorials/first-casework.mdx | 21 ++-- .../review-breg-changes-in-casework.mdx | 115 ++++++++---------- docs/site/src/data/cli-reference.yaml | 4 +- 7 files changed, 97 insertions(+), 109 deletions(-) diff --git a/docs/site/scripts/check-casework-tutorial.sh b/docs/site/scripts/check-casework-tutorial.sh index d423c9e363..b01847ad7c 100755 --- a/docs/site/scripts/check-casework-tutorial.sh +++ b/docs/site/scripts/check-casework-tutorial.sh @@ -21,9 +21,8 @@ # released bytes, then replays each registered tutorial's own shell fences from # an empty reader directory, the way a reader starts after installing the # binaries. The Base Registry Engine binaries are part of the toolset because -# the two-product page runs a registry beside Casework, and because -# `caseworkctl dev` drives `bregctl` from PATH to borrow that registry's local -# session. What CI runs is what a reader copies. +# the two-product page runs a registry beside Casework. What CI runs is what a +# reader copies. # # Usage: # scripts/check-casework-tutorial.sh replay every registered tutorial @@ -70,7 +69,7 @@ # CASEWORK_TUTORIAL_CARGO_PROFILE ci (default) or release # CASEWORK_TUTORIAL_DOCS_ROOT docs content directory override (tests) # -# CASEWORKCTL_DEV_CASEWORK_PORT, CASEWORKCTL_DEV_MINT_PORT and +# CASEWORKCTL_DEV_CASEWORK_PORT, CASEWORKCTL_DEV_ISSUER_PORT and # CASEWORKCTL_DEV_DATABASE_PORT are deliberately not set here. The tutorial's # own commands pass no port flags, so leaving the three unset replays the # default ports a reader gets. They reach `caseworkctl dev` through the @@ -229,9 +228,9 @@ load_spec() { tutorials/review-breg-changes-in-casework) # The page opens with two install one-liners, which this gate replaces # with the toolset under test. It starts a registry with `bregctl dev` - # and then Casework with `caseworkctl dev --source-project`, which - # borrows the registry session's issuer, so the registry has to be - # running first and both sessions are stopped at the end. + # and then Casework through the bounded --source-project bridge, so + # the registry has to be running first and both sessions are stopped + # at the end. SPEC_STEPS=( "run:Create the two projects" "run:Connect the registry to Casework" @@ -298,8 +297,8 @@ SHIM_DIR="$WORK_ROOT/bin" # and on the two-product page `bregctl dev` beside it, running with a database # container behind each, and deleting the work root alone would orphan those # containers. Stopping with --remove is idempotent, so a journey that already -# stopped its own sessions costs nothing here. Casework sessions stop first, -# because each one borrows the issuer of the registry session beside it. +# stopped its own sessions costs nothing here. Casework sessions stop first so +# they no longer reconcile against a registry that is stopping. # Returns non-zero when a session was left behind, which is what keeps its # project under the work root for a second attempt. stop_dev_sessions() { @@ -391,11 +390,10 @@ prepare_toolset() { # The tutorials call the binaries by name, `caseworkctl dev` resolves # `casework` and `bregctl` from PATH, and `bregctl dev` resolves - # `breg` the same way, so serve all five from a shim dir. + # `breg` the same way, so serve all four from a shim directory. mkdir -p "$SHIM_DIR" ln -s "$CASEWORK_BIN" "$SHIM_DIR/casework" ln -s "$CASEWORKCTL_BIN" "$SHIM_DIR/caseworkctl" - ln -s "$SHIM_DIR/mint" ln -s "$BREG_BIN" "$SHIM_DIR/breg" ln -s "$BREGCTL_BIN" "$SHIM_DIR/bregctl" } diff --git a/docs/site/scripts/check-casework-tutorial.test.mjs b/docs/site/scripts/check-casework-tutorial.test.mjs index d0098153ce..60c1921166 100644 --- a/docs/site/scripts/check-casework-tutorial.test.mjs +++ b/docs/site/scripts/check-casework-tutorial.test.mjs @@ -144,10 +144,9 @@ test('the registered journey runs every profile the page teaches', async () => { } }); -// The two-product journey has to start the registry before Casework, because -// `caseworkctl dev` borrows the registry session's issuer, and it has to stop -// both at the end: a replay that stopped only one would hold a database -// container after the gate exits. +// The two-product journey has to start the registry before Casework can bind +// and reconcile its source, and it has to stop both at the end: a replay that +// stopped only one would hold a database container after the gate exits. test('the two-product journey starts the registry first and stops both sessions', async () => { const steps = extractBashArray(await reviewSpec(), 'SPEC_STEPS').map((step) => step.replaceAll('"', ''), @@ -199,7 +198,7 @@ async function runPrepareToolset() { const binDir = join(root, 'supplied'); const shimDir = join(root, 'bin'); await mkdir(binDir); - const names = ['casework', 'caseworkctl', 'mint', 'breg', 'bregctl']; + const names = ['casework', 'caseworkctl', 'breg', 'bregctl']; for (const name of names) { await writeFile(join(binDir, name), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }); } @@ -219,7 +218,7 @@ async function runPrepareToolset() { try { const result = await runShell( `CASEWORK_BIN='${join(binDir, 'casework')}' CASEWORKCTL_BIN='${join(binDir, 'caseworkctl')}' ` + - `MINT_BIN='${join(binDir, 'mint')}' BREG_BIN='${join(binDir, 'breg')}' ` + + `BREG_BIN='${join(binDir, 'breg')}' ` + `BREGCTL_BIN='${join(binDir, 'bregctl')}' bash ${harness}`, ); return { ...result, names }; @@ -229,8 +228,7 @@ async function runPrepareToolset() { } // The two-product page calls `bregctl` and `breg` by name beside the Casework -// binaries, and `caseworkctl dev` drives `bregctl` from PATH to borrow the -// registry session, so the shim directory has to serve all five. +// binaries, so the shim directory has to serve all four. test('the toolset serves the Base Registry Engine binaries beside the Casework ones', async () => { const { code, output, names } = await runPrepareToolset(); assert.equal(code, 0, output); @@ -291,8 +289,8 @@ async function runStopDevSessions({ stateFiles, stub }) { // page `bregctl dev` beside it, running with a database container behind each, // and deleting the work root alone would orphan those containers. The outer // cleanup stops every session the replay started, with the toolset under test, -// and reclaims the containers and volumes. Casework sessions stop first, -// because each one borrows the issuer of the registry session beside it. +// and reclaims the containers and volumes. Casework sessions stop first so +// they no longer reconcile against a registry that is stopping. test('the cleanup stops every local development session the replay started', async () => { const { code, output, readerDir, calls } = await runStopDevSessions({ stateFiles: [ @@ -418,7 +416,7 @@ test('the gate sets none of the development port overrides', async () => { const source = await readFile(gate, 'utf8'); for (const name of [ 'CASEWORKCTL_DEV_CASEWORK_PORT', - 'CASEWORKCTL_DEV_MINT_PORT', + 'CASEWORKCTL_DEV_ISSUER_PORT', 'CASEWORKCTL_DEV_DATABASE_PORT', ]) { assert.doesNotMatch(source, new RegExp(`^[^#\\n]*${name}=`, 'mu'), `${name} must stay unset`); diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 724543db87..2c8a3e9102 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -40,9 +40,9 @@ relevant product pages on this site rather than duplicating release notes. [retain, erase, and settle](../operate/casework-retention/), the [API contract](../reference/apis/registry-casework/), and a Casework section in the [client API reference](../reference/client-api/). The installer - `casework-install.sh` installs `casework`, `caseworkctl`, and `mint` together, - and `caseworkctl dev` starts a retained local runtime with its PostgreSQL - container, token issuer, and seeded directory. + `casework-install.sh` installs `casework` and `caseworkctl`, and + `caseworkctl dev start` starts a retained local runtime with its PostgreSQL + container, pinned stock issuer, and seeded directory. {/* Evidence: crates/registry-casework/install.sh; crates/registry-caseworkctl/src/dev/mod.rs; docs/site/scripts/check-casework-tutorial.sh. */} @@ -171,10 +171,11 @@ relevant product pages on this site rather than duplicating release notes. reaches your terminal with its code, its `journeys[i].steps[j]` path, and its own message. See [author journeys](../configure/breg-journeys/#troubleshooting). -- `bregctl dev` compares the `breg` and `mint` it resolved against its own - version before it starts anything, and refuses a prerequisite from another - release by name, giving both versions and the fix. Previously the mismatch - surfaced much later as a refused package or an unready database. +- `bregctl dev start` compares the `breg` it resolved against its own version + before it starts anything, and refuses a binary from another release by name, + giving both versions and the fix. The issuer runs from the pinned stock image. + Previously a binary mismatch surfaced much later as a refused package or an + unready database. - A refused fixture logical reference names its class: a field the entity does not declare, a field the access profile grant does not make writable, a request body with no field, a step identifier that is not stable, a step diff --git a/docs/site/src/content/docs/configure/casework.mdx b/docs/site/src/content/docs/configure/casework.mdx index 5b746be25d..e4cc45e5f6 100644 --- a/docs/site/src/content/docs/configure/casework.mdx +++ b/docs/site/src/content/docs/configure/casework.mdx @@ -484,7 +484,10 @@ caseworkctl source add ./tutorial-work/project \ ``` `source add` drives your own `bregctl` of the same Registry Stack version, requires it on `PATH` or named with `--bregctl-bin`, runs its public `check` and `explain change-requests`, and refuses a register whose selected request is not staged or not manually applied. -Without `--apply` it previews: it reports the two changes it would make to the BReg project, `/entities/scope-correction/events/casework-lifecycle-v1` and `/accessProfiles/casework-reader`, prints both candidate fragments, and reports `status: preview` and `activation: not_performed`. +Without `--apply` it previews the six places it would update in the BReg project: the lifecycle +event, the `casework-reader` access profile, its local reader client, and the three Casework clients. +The report includes the exact candidate fragments, `status: preview`, and +`activation: not_performed`. Read the patch before you take the next step. ```sh @@ -502,7 +505,11 @@ The command writes no secret and activates nothing: its report says `activation: Run `caseworkctl check` again, and the source description changes to `checked`. Connecting a source is authoring, and serving one is deployment. -Locally, `caseworkctl dev` serves a connected project only beside a running `bregctl dev` session for the registry it names: pass `--source-project tutorial-work/registry`, and the Casework session borrows that registry session's Mint as its issuer, exports each Casework client as a registry client with the same principal, and binds the source to the running registry, reconciling every five seconds. +Locally, `caseworkctl dev start` serves a connected project only beside a running +`bregctl dev start` session for the registry it names. Pass +`--source-project tutorial-work/registry`; the Casework session reuses that registry session's +stock issuer, exports each Casework client as a registry client with the same principal, and binds +the source to the running registry, reconciling every five seconds. Without a running registry session the command stops and names the `--source-project` argument it needs, because every source binding needs a running source system and its own reader credential. [Review Base Registry Engine changes in Casework](../../tutorials/review-breg-changes-in-casework/) walks that local journey end to end. For a deployment, package the connected project and hand it to an operator, who adds the matching source binding to the runtime file. diff --git a/docs/site/src/content/docs/tutorials/first-casework.mdx b/docs/site/src/content/docs/tutorials/first-casework.mdx index d7a4195254..68b7953441 100644 --- a/docs/site/src/content/docs/tutorials/first-casework.mdx +++ b/docs/site/src/content/docs/tutorials/first-casework.mdx @@ -152,8 +152,7 @@ Ask the local issuer for a token as the `requester` client. The command writes a file that `curl` can send: ```sh -umask 077 -caseworkctl dev token tutorial-work/casework requester +caseworkctl dev token requester tutorial-work/casework ``` `caseworkctl dev token` requests a fresh token through the registered client and reports the private @@ -162,7 +161,7 @@ header-file path. The token carries the `casework:request` scope and lasts five :::caution[Header files hold live tokens] `$headers/requester.header` and the other header files written later each hold a bearer token that acts as that client until it expires. -`umask 077` makes them readable only by your user. +The development lifecycle makes them readable only by your user. Keep them out of version control, screenshots, and support messages. ::: @@ -228,7 +227,7 @@ own request later. Request a second token, this time as the `staff` client: ```sh -caseworkctl dev token tutorial-work/casework staff +caseworkctl dev token staff tutorial-work/casework ``` List the [work items](../../reference/glossary/#work-item) the Staff client's teams serve: @@ -331,7 +330,7 @@ Renew the Staff token before you act, so time spent reading the inbox cannot tur an authentication refusal: ```sh -caseworkctl dev token tutorial-work/casework staff +caseworkctl dev token staff tutorial-work/casework ``` Take the identifier and the offered `ifMatch` value from the inbox rather than typing them: @@ -412,7 +411,7 @@ new `ifMatch`: claiming an item is what opens deciding it. Renew the Staff token again before the two decision attempts: ```sh -caseworkctl dev token tutorial-work/casework staff +caseworkctl dev token staff tutorial-work/casework ``` Try to confirm the renewal with the `ifMatch` value the inbox offered before the claim: @@ -498,7 +497,7 @@ Renew the Requester token before reading its own [terminal feed](../../reference/glossary/#terminal-feed): ```sh -caseworkctl dev token tutorial-work/casework requester +caseworkctl dev token requester tutorial-work/casework ``` Read the feed: @@ -551,7 +550,7 @@ A Supervisor resolves the opaque handle to a person, one decision at a time. Request a Supervisor token: ```sh -caseworkctl dev token tutorial-work/casework supervisor +caseworkctl dev token supervisor tutorial-work/casework ``` Read the [accountability record](../../reference/glossary/#accountability-record) for the decision @@ -608,7 +607,7 @@ Renew the Requester token so the first refusal proves the profile boundary rathe expiry: ```sh -caseworkctl dev token tutorial-work/casework requester +caseworkctl dev token requester tutorial-work/casework ``` Ask for the Staff inbox with the Requester token: @@ -632,7 +631,7 @@ The Requester profile carries no authority over the queue, whatever it asks for. Renew the Staff token, then ask for the same inbox while selecting the `supervisor` profile: ```sh -caseworkctl dev token tutorial-work/casework staff +caseworkctl dev token staff tutorial-work/casework ``` ```sh @@ -717,7 +716,7 @@ All of it came from the YAML in `tutorial-work/casework`. ### Renew an expired token A local token lasts five minutes. -Run `caseworkctl dev token tutorial-work/casework ` again; it replaces that client's header file. +Run `caseworkctl dev token tutorial-work/casework` again; it replaces that client's header file. If it fails, the runtime is stopped: start it again with the command from [Start Casework](#start-casework), then renew. diff --git a/docs/site/src/content/docs/tutorials/review-breg-changes-in-casework.mdx b/docs/site/src/content/docs/tutorials/review-breg-changes-in-casework.mdx index ee475dd10d..20857f5548 100644 --- a/docs/site/src/content/docs/tutorials/review-breg-changes-in-casework.mdx +++ b/docs/site/src/content/docs/tutorials/review-breg-changes-in-casework.mdx @@ -37,15 +37,13 @@ Everything you keep goes into one directory, `tutorial-work`. ## Install both products -Install `breg`, `bregctl`, and `mint`, then `casework` and `caseworkctl`. -[Registry Mint](../../reference/glossary/#registry-mint) issues the local access tokens this -tutorial uses, standing in for the identity provider a deployment would have; both installers -ship it, and either copy serves: +Install `breg` and `bregctl`, then `casework` and `caseworkctl`. +Each product's local development lifecycle uses the pinned stock issuer to obtain short-lived +access tokens, standing in for the identity provider a deployment would have: ```sh curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash bregctl --version -mint --version ``` ```sh @@ -56,8 +54,8 @@ caseworkctl --version Each installer checks its binaries against the release `SHA256SUMS` before anything reaches `~/.local/bin`, and installs them together or not at all. Keep that directory on your `PATH`. -The two commands must print the same version, because `caseworkctl dev` drives `bregctl` and -refuses one from another release. +The two commands must print the same version, because `caseworkctl source add` and the local +`--source-project` bridge drive `bregctl` and refuse one from another release. :::note[Before these binaries serve anyone else] The URL takes the latest release, and a deployment pins a version instead. @@ -95,7 +93,7 @@ The `professional-licences` starter is a registry of professional licences, with `professional-license` entity and a `scope-correction` change request that proposes a correction to one licence and applies it only after a review stage named `review`. Its `dev-clients.yaml` declares the `reader`, `editor`, and `reviewer` clients the local session -registers with Mint. +registers with its development issuer. The `professional-review` template is a Casework deployment over that registry: one source, `professional-register`, using the `breg` adapter; one request kind, the `scope-correction` entity, @@ -132,8 +130,8 @@ In `registry.yaml`, a lifecycle event on `scope-correction` tells Casework when and a `casework-reader` access profile lets Casework read requests and nothing else. In `dev-clients.yaml`, a `casework-reader` client is the credential the local Casework session reads the registry with, and `administrator`, `supervisor`, and `staff` are the Casework project's -own people, registered with the registry's Mint so one token serves both products. Staff and -Supervisor are explicitly admitted to BReg because they review and apply its requests; +own people, registered with the registry's development issuer so one token serves both products. +Staff and Supervisor are explicitly admitted to BReg because they review and apply its requests; Administrator remains a Casework-only caller. Read the preview, then write it: @@ -171,13 +169,14 @@ Nothing here is configured for anyone else's data. Start the registry: ```sh -bregctl dev tutorial-work/registry +bregctl dev start tutorial-work/registry ``` The first start downloads the pinned PostgreSQL image, so it takes longer than the later ones. The command returns once the registry answers, with `status ready` in its report. -`dev` started PostgreSQL in a container, started Mint, registered the seven clients from -`dev-clients.yaml` under a fresh key each, built the registry package, and started `breg`. +`dev start` started PostgreSQL and the pinned stock issuer in containers, registered the seven +clients from `dev-clients.yaml` under a fresh key each, built the registry package, and started +`breg`. The report names the registry address, the token endpoint, the audience, and one credential directory per client, as absolute paths under your directory: @@ -187,7 +186,7 @@ bregctl dev succeeded. status ready project /tutorial-work/registry breg url http://127.0.0.1:8090 - token endpoint http://127.0.0.1:8091/token + token endpoint http://127.0.0.1:8091/oauth2/token audience urn:breg:dev: ``` @@ -205,14 +204,14 @@ The services keep running after the command returns, so this one terminal is eno Start the Casework project beside the running registry: ```sh -caseworkctl dev tutorial-work/casework --source-project tutorial-work/registry +caseworkctl dev start tutorial-work/casework --source-project tutorial-work/registry ``` `--source-project` names the registry project whose local session stands in for the deployed registry. The command returns once Casework answers, with `status: ready` in its report. -`dev` started a second PostgreSQL container, read the registry session's state, borrowed its Mint as -the token issuer, exported the `casework-reader` credential and the three human clients from the +`dev start` started a second PostgreSQL container, read the registry session's state, reused its +stock issuer, exported the `casework-reader` credential and the three human clients from the registry session, wrote an operator file binding the source to the registry's address, migrated the database, started `casework`, and seeded the [directory](../../reference/glossary/#directory) from `dev-clients.yaml`. @@ -230,22 +229,19 @@ caseworkUrl: http://127.0.0.1:8092 sources: {"professional-register":{"bregUrl":"http://127.0.0.1:8090","project":"/tutorial-work/registry"}} stateFile: /tutorial-work/casework/.casework/dev/state.json status: ready -tokenEndpoint: http://127.0.0.1:8091/token +tokenEndpoint: http://127.0.0.1:8091/oauth2/token ``` The `clients`, `directory`, `journal`, `operatorConfig`, and `project` lines between them are omitted here. -The audience and the token endpoint are the registry session's: a Staff token minted there is +The audience and the token endpoint are the registry session's: a Staff token issued there is valid at both products, which is what lets Casework carry your authority to the registry. -Use the two addresses from the reports. These assignments use the default ports or the -`CASEWORKCTL_DEV_CASEWORK_PORT` override; if you started the registry with `--mint-port`, replace -the token endpoint: +Use the Casework address from the report. This assignment uses the default port or the +`CASEWORKCTL_DEV_CASEWORK_PORT` override: ```sh casework_url="http://127.0.0.1:${CASEWORKCTL_DEV_CASEWORK_PORT:-8092}" -token_endpoint="http://127.0.0.1:8091/token" -credentials=tutorial-work/casework/.casework/dev/credentials ``` Keep this terminal open so the values remain available to the later commands. @@ -289,27 +285,22 @@ add` declared and through reconciliation, which the local session runs every fiv ## Open the inbox as Staff -Ask Mint for a token as the `staff` client and write it into a file `curl` can send as an -authorization header: +Ask Casework's development lifecycle for a fresh token as the `staff` client. It writes an +owner-only header file that `curl` can send directly: ```sh -umask 077 -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token staff tutorial-work/casework ``` -Success prints nothing. -`mint token` signs a request with the client's private key and posts it to the token endpoint, the -way an application would; the token carries the `casework:staff` scope Casework's `staff` profile -requires and the `starter:reviewer` scope the registry's `reviewer` profile requires, and lasts five -minutes. +The report names +`tutorial-work/casework/.casework/dev/secrets/staff.header`. The lifecycle signs the token request +with the retained client key; the token carries the `casework:staff` scope Casework's `staff` +profile requires and the `starter:reviewer` scope the registry's `reviewer` profile requires, and +lasts five minutes. :::caution[Header files hold live tokens] -`tutorial-work/staff.header` holds a bearer token that acts as Staff at both products until it -expires. -`umask 077` makes it readable only by your user. +The generated `staff.header` holds a bearer token that acts as Staff at both products until it +expires. The development lifecycle makes it readable only by your user. Keep it out of version control, screenshots, and support messages. ::: @@ -320,7 +311,7 @@ reconciliation rather than at the moment you submitted: ```sh for _ in $(seq 1 30); do curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --output tutorial-work/inbox.json --write-out 'HTTP %{http_code}\n' \ @@ -406,7 +397,7 @@ the item was observed. {/* Evidence: crates/registry-casework/src/http.rs, list_items() and source_profile(); crates/registry-casework-breg/src/lib.rs; - crates/registry-mint/src/cli.rs. */} + crates/registry-caseworkctl/src/dev/mod.rs, fresh_token(). */} ## Approve the review @@ -416,10 +407,7 @@ Renew the Staff token before you act, so time spent reading the inbox cannot tur an authentication refusal: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token staff tutorial-work/casework ``` Take the identifier and the offered `ifMatch` value of the review item from the inbox rather than @@ -434,7 +422,7 @@ Claim it: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --header "If-Match: $claim_match" \ @@ -495,8 +483,8 @@ python3 -c 'import json; item = json.load(open("tutorial-work/claimed.json"))["i } ``` -The holder is the issuer and subject Mint put in the token, the registry's Mint and the -`registry_principal` claim the `staff` client declares, and the revision moved to 2. +The holder is the issuer and subject in the token, paired with the `registry_principal` claim the +`staff` client declares, and the revision moved to 2. `actions` now offers the three decisions the registry's `review` stage admits, beside `release` and `delegate`, each carrying the new `ifMatch`. @@ -512,7 +500,7 @@ Approve: ```sh curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --header "If-Match: $approve_match" \ @@ -578,13 +566,10 @@ work item on the same request. Renew the token, then poll the inbox for an item whose `occurrenceKind` is `application`: ```sh -mint token --url "$token_endpoint" \ - --client-id "$(cat "$credentials/staff/client-id")" \ - --key "$credentials/staff/assertion-key.jwk" \ - | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.header +caseworkctl dev token staff tutorial-work/casework for _ in $(seq 1 30); do curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --output tutorial-work/inbox.json --write-out 'HTTP %{http_code}\n' \ @@ -635,7 +620,7 @@ Claim it the same way: item_id=$(python3 -c 'import json; items = json.load(open("tutorial-work/inbox.json"))["items"]; print(next(item["itemId"] for item in items if item["occurrenceKind"] == "application"))') claim_match=$(python3 -c 'import json; items = json.load(open("tutorial-work/inbox.json"))["items"]; actions = next(item["actions"] for item in items if item["occurrenceKind"] == "application"); print(next(action["ifMatch"] for action in actions if action["operation"] == "claim"))') curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --header "If-Match: $claim_match" \ @@ -657,7 +642,7 @@ Build the application from it and send it: python3 -c 'import json; item = json.load(open("tutorial-work/claimed.json"))["item"]; json.dump({"displayedBinding": item["binding"], "sourceProfileId": "reviewer", "operation": "apply"}, open("tutorial-work/apply.json", "w"), indent=2)' apply_match=$(python3 -c 'import json; actions = json.load(open("tutorial-work/claimed.json"))["item"]["actions"]; print(next(action["ifMatch"] for action in actions if action["operation"] == "apply"))') curl --silent --show-error \ - --header @tutorial-work/staff.header \ + --header @tutorial-work/casework/.casework/dev/secrets/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header 'Registry-Source-Profile: reviewer' \ --header "If-Match: $apply_match" \ @@ -801,7 +786,7 @@ carried your decisions. ### Renew an expired token A local token lasts five minutes. -Run the `mint token` command for the `staff` client again; it replaces the header file. +Run `caseworkctl dev token staff tutorial-work/casework` again; it replaces the header file. If it fails, the registry session is stopped: start it again with the command from [Start the registry](#start-the-registry), then start Casework, then renew. @@ -809,22 +794,22 @@ If it fails, the registry session is stopped: start it again with the command fr | Symptom | Next move | | --- | --- | -| `bregctl`, `caseworkctl`, or `mint` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | -| `caseworkctl dev` refuses over a reported version | The `bregctl`, `casework`, or `mint` it resolved comes from another release than `caseworkctl`. The refusal names both versions. Install both products from the same release, or put the matching build first on `PATH`. | -| `caseworkctl dev` refuses a project that declares a source | It was started without `--source-project`. A source binding needs a running registry session; pass `--source-project tutorial-work/registry` after `bregctl dev` reports `ready`. | -| `bregctl dev` refuses a port | Something else listens on 8090, 8091, or 55432. Pass `--breg-port`, `--mint-port`, or `--database-port` on the first start; later starts keep the ports you chose. With `--mint-port`, change the `token_endpoint` assignment in [Start Casework](#start-casework). | -| `caseworkctl dev` refuses a port | Something else listens on 8092 or 55433. Set `CASEWORKCTL_DEV_CASEWORK_PORT` or `CASEWORKCTL_DEV_DATABASE_PORT`, or pass `--casework-port` or `--database-port`, on the first start; the `casework_url` assignment picks the first up. The session borrows the registry's Mint, so its own Mint port is unused. | +| `bregctl` or `caseworkctl` is not found | Add the installer's directory, `~/.local/bin` unless you changed it, to `PATH` in this terminal. | +| A `dev start` command refuses over a reported version | One of the product binaries it resolved comes from another release. The refusal names both versions. Install both products from the same release, or put the matching build first on `PATH`. | +| `caseworkctl dev start` refuses a project that declares a source | It was started without `--source-project`. A source binding needs a running registry session; pass `--source-project tutorial-work/registry` after `bregctl dev start` reports `ready`. | +| `bregctl dev start` refuses a port | Something else listens on 8090, 8091, or 55432. Pass `--breg-port`, `--issuer-port`, or `--database-port` on the first start; later starts keep the ports you chose. | +| `caseworkctl dev start` refuses a port | Something else listens on 8092, 8093, or 55433. Set `CASEWORKCTL_DEV_CASEWORK_PORT`, `CASEWORKCTL_DEV_ISSUER_PORT`, or `CASEWORKCTL_DEV_DATABASE_PORT`, or pass the matching port option on the first start; later starts keep the ports you chose. | | Either `dev` command fails before it reports `ready` | Read the refusal: it names the check that failed. Otherwise check that Docker is running. `bregctl dev events tutorial-work/registry` and `caseworkctl dev events tutorial-work/casework` show the retained journals; do not share the credential files beside them. | -| The inbox loop runs its full minute with no item | Check that the submit step reported `"operation": "submit_request"`, and that the registry session is still running: `bregctl dev tutorial-work/registry` reuses a running session and reports `status ready`. Then read the Casework journal with `caseworkctl dev events tutorial-work/casework` for the source reconciliation it logged. | +| The inbox loop runs its full minute with no item | Check that the submit step reported `"operation": "submit_request"`, and that the registry session is still running: `bregctl dev start tutorial-work/registry` reuses a running session and reports `status ready`. Then read the Casework journal with `caseworkctl dev events tutorial-work/casework` for the source reconciliation it logged. | | An inbox read returns `400` with code `source-profile.required` | A source-backed inbox is read under a registry profile. Send `Registry-Source-Profile: reviewer`. | -| A request returns `401` with code `authentication.refused` | The token expired, or the header file is stale. Mint it again, as [Renew an expired token](#renew-an-expired-token) describes. | +| A request returns `401` with code `authentication.refused` | The token expired, or the header file is stale. Acquire it again, as [Renew an expired token](#renew-an-expired-token) describes. | | A request returns `400` with code `request.invalid` | Another required or structured input is missing or malformed. Every request carries `Registry-Casework-Profile`, and every write also carries `Idempotency-Key`; source-backed reads name a source profile as described above. | | A decision returns `428` with code `precondition.required` | The `If-Match` header is missing. Take the `ifMatch` value from the `actions` entry for the operation you want in the claim response. | | A claim or decision returns `412` with code `precondition.failed` | The item moved. Re-read it, take the `ifMatch` value from the `actions` entry for the operation you want, and send it again. | | An inbox read or a claim returns `409` | The item is synchronizing with the registry, or another caller holds it. Read the inbox again a few seconds later; its `actions` array shows what remains open to you. | | A decision returns `422` with code `request.source-rejected` | The registry refused the promoted action body. Fix the decision request before trying again; re-reading the inbox does not fix this refusal. | | A decision returns `502` with code `source.bad-gateway` | The registry answered outside its registered contract. Check that the registry session is `ready`; if it is, inspect the Casework journal for the invalid response class. | -| You opened a fresh terminal | Run the `casework_url`, `token_endpoint`, and `credentials` assignments from [Start Casework](#start-casework) again. The header file and `tutorial-work` directory are still there. | +| You opened a fresh terminal | Run the `casework_url` assignment from [Start Casework](#start-casework) again. The header file and `tutorial-work` directory are still there. | ## Next diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 9346d43e44..fe30db95cd 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-13 reviewed_source_version: "0.30.0" -reviewed_catalog_sha256: 178d3a1515f4a339314434bd3c359af073171d3b0f85bd300b4fda929d81feee -reviewed_content_sha256: f40a029c43edbc9a8352a44b0a1fbfa4eb12acf16499da228b5fae97d2f3f324 +reviewed_catalog_sha256: e2b30d2e9727b5ea23f2c1efed53cea69b3dd3631639b8386a8758805d663e6c +reviewed_content_sha256: 16458b2b605801e79895072ed753992a2c7b3a1cbb4130309ad9310a8eb4a04a From 706d3ce21761e8940d517a8a8eefa6be724d7311 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:07:25 +0700 Subject: [PATCH 081/120] fix(breg): scope shared issuer rehearsals Signed-off-by: Jeremi Joslin --- .../tests/support/starter_policy.rs | 29 ++++++- crates/registry-bregctl/src/dev/mod.rs | 78 ++++++++++++++++--- crates/registry-bregctl/src/dev/tests.rs | 23 ++++++ .../core/tests/security-journeys.yaml | 2 +- 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/crates/registry-breg/tests/support/starter_policy.rs b/crates/registry-breg/tests/support/starter_policy.rs index 1d4a42279e..8129365c62 100644 --- a/crates/registry-breg/tests/support/starter_policy.rs +++ b/crates/registry-breg/tests/support/starter_policy.rs @@ -264,6 +264,19 @@ fn starter_runtime_config( config["identity"]["instanceId"] = json!(manifest.instance_id); config["identity"]["databaseId"] = json!(manifest.database_id); config["package"]["compilerSourceRevision"] = json!(manifest.compiler.source_revision); + config["authentication"]["oidc"]["allowedClients"] = json!(["staff", "supervisor"]); + config["authentication"]["authorityClaims"]["contextual"] = json!({ + "actorKind": "registry_actor_kind", + "purpose": "registry_purpose", + "grantId": "registry_grant_id", + "grantAuthority": "registry_grant_authority", + "grantSourceIssuer": "registry_grant_source_issuer", + "grantClient": "registry_grant_client", + "grantResource": "registry_grant_resource", + "grantExp": "registry_grant_exp", + "grantBounds": "registry_grant_bounds", + "approver": "registry_approver" + }); write_private(&path, serde_norway::to_string(&config).unwrap().as_bytes()); path } @@ -285,12 +298,17 @@ fn authored_credentials( .map(|value| value.as_str().unwrap()) .collect::>() .join(" "); - let token = idp.mint_token(json!({ + let mut token_claims = json!({ "aud": AUDIENCE, + "client_id": claims["requesterClient"].as_str().unwrap_or("staff"), "registry_principal": claims["principal"], "registry_purpose": claims["purpose"], "scope": scopes, - })); + }); + if !claims["actorKind"].is_null() { + token_claims["registry_actor_kind"] = claims["actorKind"].clone(); + } + let token = idp.mint_token(token_claims); bindings.push(SchemaTestCredentialBinding::bearer( journey["id"].as_str().unwrap(), step["id"].as_str().unwrap(), @@ -322,7 +340,12 @@ impl StarterHttp<'_> { body: Option, extra_headers: &[(&str, &str)], ) -> (StatusCode, Value, HeaderMap) { - let token = self.idp.mint_token(json!({"aud": AUDIENCE, "registry_principal": actor.principal, "registry_purpose": "starter-learning", "scope": actor.scope})); + let mut token_claims = json!({"aud": AUDIENCE, "client_id": "staff", "registry_principal": actor.principal, "registry_purpose": "starter-learning", "scope": actor.scope}); + if profile == "reviewer" { + token_claims["client_id"] = json!("supervisor"); + token_claims["registry_actor_kind"] = json!("human"); + } + let token = self.idp.mint_token(token_claims); let separator = if path.contains('?') { '&' } else { '?' }; let mut request = Request::builder() .method(method) diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index 66b9f7d796..5374c14e71 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -2195,14 +2195,44 @@ fn tokens(state: &State, clients: &Clients) -> Result<()> { /// assertion key never leaves the session's private credentials tree, and the /// credential is stored owner-only for the seeding and rehearsal steps. fn token(state: &State, id: &str) -> Result<()> { + let client: Clients = serde_json::from_slice(&private::read( + &state.root().join("clients.json"), + MAX_BYTES, + )?)?; + let scopes = client + .clients + .iter() + .find(|client| client.id == id) + .with_context(|| format!("the retained client {id} is not registered"))? + .scopes + .clone(); + token_with_scopes(state, id, scopes) +} + +fn token_with_scopes(state: &State, id: &str, scopes: Vec) -> Result<()> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .context("cannot build the dev token runtime")?; - runtime.block_on(token_async(state, id)) + runtime.block_on(token_async_with_scopes(state, id, scopes)) } async fn token_async(state: &State, id: &str) -> Result<()> { + let client: Clients = serde_json::from_slice(&private::read( + &state.root().join("clients.json"), + MAX_BYTES, + )?)?; + let scopes = client + .clients + .iter() + .find(|client| client.id == id) + .with_context(|| format!("the retained client {id} is not registered"))? + .scopes + .clone(); + token_async_with_scopes(state, id, scopes).await +} + +async fn token_async_with_scopes(state: &State, id: &str, scopes: Vec) -> Result<()> { use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig, TokenProvider}; let root = state.root(); @@ -2218,15 +2248,6 @@ async fn token_async(state: &State, id: &str) -> Result<()> { String::from_utf8(key_bytes.to_vec()).context("the retained client key is unreadable")?; let key = registry_platform_crypto::PrivateJwk::parse(&key_text) .map_err(|_| anyhow::anyhow!("the retained client key is unusable"))?; - let client: Clients = - serde_json::from_slice(&private::read(&root.join("clients.json"), MAX_BYTES)?)?; - let scopes = client - .clients - .iter() - .find(|client| client.id == id) - .with_context(|| format!("the retained client {id} is not registered"))? - .scopes - .clone(); let provider = PrivateKeyJwt::new( PrivateKeyJwtConfig::new(endpoint, id.to_owned(), key) // ThunderID v1.0.1 checks the assertion audience against the @@ -2332,6 +2353,7 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { MAX_BYTES, )?)?; let mut bindings = Vec::new(); + let mut rehearsal_scopes = BTreeMap::>::new(); for journey in journeys["journeys"] .as_array() .context("journeys must contain an array")? @@ -2347,6 +2369,7 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { let step_id = step["id"].as_str().context("journey step requires an id")?; let explicit = exact_journey_client(clients, journey_id, step_id, profile)?; let credential = if let Some(client) = explicit { + remember_rehearsal_scopes(&mut rehearsal_scopes, client, step)?; json!({"type":"bearer","tokenRef":format!("secret:file/{}-token",client.id)}) } else if step["claims"] .as_object() @@ -2355,11 +2378,15 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { json!({"type":"anonymous"}) } else { let client = journey_client(clients, journey_id, step_id, profile)?; + remember_rehearsal_scopes(&mut rehearsal_scopes, client, step)?; json!({"type":"bearer","tokenRef":format!("secret:file/{}-token",client.id)}) }; bindings.push(json!({"journeyId":journey_id,"stepId":step_id,"credential":credential})); } } + for (client, scopes) in rehearsal_scopes { + token_with_scopes(state, &client, scopes)?; + } let credentials = json!({"apiVersion":"registry.registrystack.org/breg-schema-test-credentials/v1","kind":"SchemaTestCredentials","bindings":bindings}); private::replace( &root.join("schema-test-credentials.yaml"), @@ -2408,6 +2435,37 @@ fn package(docker: &Path, state: &mut State, clients: &Clients) -> Result<()> { state.save() } +fn remember_rehearsal_scopes( + remembered: &mut BTreeMap>, + client: &config::Client, + step: &Value, +) -> Result<()> { + let scopes = step["claims"]["scopes"] + .as_array() + .context("an authenticated journey step must declare scopes")? + .iter() + .map(|scope| { + scope + .as_str() + .map(str::to_owned) + .context("journey scopes must be strings") + }) + .collect::>>()?; + if scopes.iter().any(|scope| !client.scopes.contains(scope)) { + bail!("journey scopes exceed the bound local client's registered scopes"); + } + match remembered.get(&client.id) { + Some(existing) if existing != &scopes => { + bail!("one local client cannot bind journey steps with different scope sets") + } + None => { + remembered.insert(client.id.clone(), scopes); + } + _ => {} + } + Ok(()) +} + /// Clear only this journal's rebuild outputs, preserving predecessor packages. /// The native schema-test receipt is a value-free public compiler artifact; /// runtime configuration remains an owner-only deployment binding. diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index 67b4dbf528..f0f38f8274 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -520,6 +520,29 @@ clients: .contains("unknown or profile-mismatched")); } +#[test] +fn rehearsal_tokens_request_only_the_exact_fixture_scope_subset() { + let client = config::Client { + id: "supervisor".into(), + access_profiles: vec!["reviewer".into()], + allow_breg_access: true, + allow_human_fixture: true, + scopes: vec!["casework:supervisor".into(), "starter:reviewer".into()], + claims: BTreeMap::new(), + test_bindings: Vec::new(), + client_id_file: None, + assertion_key_file: None, + }; + let step = json!({"claims":{"scopes":["starter:reviewer"]}}); + let mut remembered = BTreeMap::new(); + + remember_rehearsal_scopes(&mut remembered, &client, &step).unwrap(); + + assert_eq!(remembered["supervisor"], ["starter:reviewer"]); + let widened = json!({"claims":{"scopes":["unregistered"]}}); + assert!(remember_rehearsal_scopes(&mut remembered, &client, &widened).is_err()); +} + #[test] fn credential_publication_recovers_one_owned_half_and_refuses_conflicting_bytes() { let (_temp, state, mut clients, files) = fixture(); diff --git a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml index eeab0b37d0..7022aeb165 100644 --- a/products/breg/starters/professional-licences/core/tests/security-journeys.yaml +++ b/products/breg/starters/professional-licences/core/tests/security-journeys.yaml @@ -209,7 +209,7 @@ "entity": "scope-correction", "accessProfile": "reviewer", "claims": { - "principal": "professional-review-supervisor", + "principal": "synthetic-editor", "scopes": [ "starter:reviewer" ], From 854f685f7862c7fedefaa88750a7ae4a19f6a6bc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:08:52 +0700 Subject: [PATCH 082/120] test(breg): simplify retired toolset fixture Signed-off-by: Jeremi Joslin --- crates/registry-breg/tests/install_script.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/crates/registry-breg/tests/install_script.rs b/crates/registry-breg/tests/install_script.rs index 574587d6a4..db33bb656e 100644 --- a/crates/registry-breg/tests/install_script.rs +++ b/crates/registry-breg/tests/install_script.rs @@ -303,16 +303,10 @@ exec /bin/mv "${arguments[@]}" fn preinstall_pointer_toolset_without_ctl(&self) { let toolset = self.install_dir.join(".breg-toolset.earlier"); fs::create_dir_all(&toolset).unwrap(); - for binary in ["breg"] { - let path = toolset.join(binary); - fs::write(&path, format!("{binary} previous binary\n")).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - std::os::unix::fs::symlink( - format!(".breg-current/{binary}"), - self.install_dir.join(binary), - ) - .unwrap(); - } + let path = toolset.join("breg"); + fs::write(&path, "breg previous binary\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink(".breg-current/breg", self.install_dir.join("breg")).unwrap(); std::os::unix::fs::symlink( ".breg-toolset.earlier", self.install_dir.join(".breg-current"), From 946026ee7e907fb7ef08f472b78d97744494ffc2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:18:59 +0700 Subject: [PATCH 083/120] test(casework): expect task grant migration Signed-off-by: Jeremi Joslin --- crates/registry-casework/tests/postgres_transactions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/registry-casework/tests/postgres_transactions.rs b/crates/registry-casework/tests/postgres_transactions.rs index b5cfbbb8a9..eb2f7c36bf 100644 --- a/crates/registry-casework/tests/postgres_transactions.rs +++ b/crates/registry-casework/tests/postgres_transactions.rs @@ -972,7 +972,7 @@ async fn repeated_migration_is_a_ledger_no_op_and_never_drops_the_occurrence_ind let (store, client, schema) = isolated_schema("migrate").await; store.migrate().await.expect("first migration"); let applied = applied_versions(&client).await; - assert_eq!(applied, (1..=13).collect::>()); + assert_eq!(applied, (1..=14).collect::>()); let index = occurrence_index(&client, &schema).await; assert!(index.1, "the occurrence identity index is unique"); @@ -999,13 +999,13 @@ async fn migration_13_adds_sync_claim_indexes_to_an_existing_schema() { DELETE FROM casework_schema_migrations WHERE version=13;", ) .await - .expect("simulate a database at migration 12"); + .expect("simulate a schema missing migration 13 indexes"); store.migrate().await.expect("apply sync claim indexes"); assert_eq!( applied_versions(&client).await, - (1..=13).collect::>() + (1..=14).collect::>() ); let indexes: Vec = client .query( From 220ce8b54165a8d561723d8e9506887f4239e7d1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:20:23 +0700 Subject: [PATCH 084/120] ci(casework): build complete tutorial toolset Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5b0788f9f..ff21da0b1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1331,13 +1331,15 @@ jobs: working-directory: docs/site run: npm run check:tutorial:casework:dry-run - - name: Build the Registry Casework toolset under test + - name: Build the Registry Casework and BReg toolset under test shell: bash run: | set -euo pipefail CARGO_TARGET_DIR="target/casework-tutorial-source" \ cargo build --locked --profile ci \ - -p registry-casework -p registry-caseworkctl --bins + -p registry-casework -p registry-caseworkctl \ + -p registry-breg --features registry-breg/runtime \ + -p registry-bregctl --bins - name: Verify the retained local development lifecycle shell: bash @@ -1354,6 +1356,8 @@ jobs: env: CASEWORK_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/casework CASEWORKCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/caseworkctl + BREG_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/breg + BREGCTL_BIN: ${{ github.workspace }}/target/casework-tutorial-source/ci/bregctl run: | set -euo pipefail # The tutorial passes no port flags, and this runner listens on none From 96ca66649226d58298a2a66ca8add66dc8ba261a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:22:01 +0700 Subject: [PATCH 085/120] fix(ci): align retirement checks and identifier hashes Signed-off-by: Jeremi Joslin --- products/breg/quickstart/self-test.sh | 6 +- .../identifiers/generated/catalog.v1.json | 94 +++++++++---------- release/scripts/test_release_rehearsal.py | 3 +- 3 files changed, 52 insertions(+), 51 deletions(-) diff --git a/products/breg/quickstart/self-test.sh b/products/breg/quickstart/self-test.sh index fb25cea788..58d68629a9 100755 --- a/products/breg/quickstart/self-test.sh +++ b/products/breg/quickstart/self-test.sh @@ -3,9 +3,9 @@ set -euo pipefail quickstart_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) bash -n "$quickstart_dir/run.sh" "$quickstart_dir/query.sh" python3 -m py_compile "$quickstart_dir/support/quickstart.py" -rg -q 'bregctl" --format json dev start' "$quickstart_dir/run.sh" -rg -q 'dev token "\$client"' "$quickstart_dir/run.sh" -if rg -ni 'registry[ -]mint|registry-mint|clientAuthentication:|mint[_-](port|bin|origin)' "$quickstart_dir/run.sh" "$quickstart_dir/support/quickstart.py"; then +grep -Fq 'bregctl" --format json dev start' "$quickstart_dir/run.sh" +grep -Fq 'dev token "$client"' "$quickstart_dir/run.sh" +if grep -Ein 'registry[ -]mint|registry-mint|clientAuthentication:|mint[_-](port|bin|origin)' "$quickstart_dir/run.sh" "$quickstart_dir/support/quickstart.py"; then echo 'quickstart still contains a retired issuer implementation' >&2; exit 1 fi echo 'Base Registry Engine quickstart structural self-test passed.' diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index b3b7b7835f..0c4cb5940d 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -404,7 +404,7 @@ "description": "Choose cover assignments that do not return to an earlier person during a shared period.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "absence.cover-cycle", @@ -423,7 +423,7 @@ "description": "Set until later than from.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "absence.invalid-period", @@ -442,7 +442,7 @@ "description": "Adjust the period so this person has no overlapping absence.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "absence.overlap", @@ -461,7 +461,7 @@ "description": "Choose a different staff member as cover.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "absence.self-cover", @@ -480,7 +480,7 @@ "description": "The bearer credential is missing, invalid, or expired. Sign in again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "authentication.refused", @@ -499,7 +499,7 @@ "description": "Create a new recompute preview and review it before applying.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "clock.recompute-preview-expired", @@ -518,7 +518,7 @@ "description": "This cursor has expired. Start again without a cursor and deduplicate entries by eventId.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "cursor.expired", @@ -537,7 +537,7 @@ "description": "The cursor is invalid for this request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "cursor.invalid", @@ -556,7 +556,7 @@ "description": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "idempotency.expired", @@ -575,7 +575,7 @@ "description": "This idempotency key was used for a different request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "idempotency.key-reused", @@ -594,7 +594,7 @@ "description": "Your current Casework authority does not allow this operation.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "operation.not-authorized", @@ -613,7 +613,7 @@ "description": "The item or directory changed since you loaded it. Reload and try again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "precondition.failed", @@ -632,7 +632,7 @@ "description": "This mutation requires the revision that you loaded.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "precondition.required", @@ -651,7 +651,7 @@ "description": "The selected Casework profile does not authorize this request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "profile.not-authorized", @@ -670,7 +670,7 @@ "description": "This action is reserved for a human session.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "profile.not-human", @@ -689,7 +689,7 @@ "description": "The request body exceeds the one MiB limit.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.body-too-large", @@ -708,7 +708,7 @@ "description": "The Casework request is invalid.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.invalid", @@ -727,7 +727,7 @@ "description": "This route does not accept that HTTP method.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.method-not-allowed", @@ -746,7 +746,7 @@ "description": "The requested Casework route does not exist.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.not-found", @@ -765,7 +765,7 @@ "description": "The reason field is not supported for approve or apply on this source. Omit it and try again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.reason-unsupported", @@ -784,7 +784,7 @@ "description": "The source refused the request body. Fix the request before trying again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.source-rejected", @@ -803,7 +803,7 @@ "description": "The request body does not match the Casework contract.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.unprocessable", @@ -822,7 +822,7 @@ "description": "Send a JSON request body with Content-Type application/json.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "request.unsupported-media-type", @@ -841,7 +841,7 @@ "description": "Casework could not complete the request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "runtime.failure", @@ -860,7 +860,7 @@ "description": "Casework storage is unavailable. Try again after the service recovers.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "service.unavailable", @@ -879,7 +879,7 @@ "description": "Omit the Registry-Source-Profile header for this request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source-profile.not-applicable", @@ -898,7 +898,7 @@ "description": "Send the Registry-Source-Profile header to select the source profile for this request.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source-profile.required", @@ -917,7 +917,7 @@ "description": "The source returned a response that does not match its registered contract.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source.bad-gateway", @@ -936,7 +936,7 @@ "description": "The requested source is not registered.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source.not-found", @@ -955,7 +955,7 @@ "description": "The bound source record is no longer at the registered location.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source.record-missing", @@ -974,7 +974,7 @@ "description": "The source refused the reviewer binding. Check the selected source profile and credential.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source.reviewer-not-authorized", @@ -993,7 +993,7 @@ "description": "The event's signature did not verify.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "source.signature-invalid", @@ -1012,7 +1012,7 @@ "description": "Someone else claimed this item a moment ago.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.already-claimed", @@ -1031,7 +1031,7 @@ "description": "You do not hold this item. Claim it first, or ask a supervisor.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.not-holder", @@ -1050,7 +1050,7 @@ "description": "The registry did not offer this action to you. Refresh to check again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.not-offered", @@ -1069,7 +1069,7 @@ "description": "The registry did not show you this request, so Casework cannot show you the item.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.not-visible", @@ -1088,7 +1088,7 @@ "description": "The proposal changed since you read it. Your private draft is retained.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.proposal-changed", @@ -1107,7 +1107,7 @@ "description": "We could not confirm the result of your last action. Recover the original attempt; do not decide again.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.recovery-pending", @@ -1126,7 +1126,7 @@ "description": "The registry is not answering. We cannot confirm the current item or its actions. Your private draft is retained.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.source-unavailable", @@ -1145,7 +1145,7 @@ "description": "A revised proposal replaced this item. Open the current one.", "source": { "path": "crates/registry-casework/src/problem.rs", - "sha256": "cdfff6b372b5b09b89c77a77b116b22ca41cd4fba533456b3f340eb8a18dcad9" + "sha256": "5a878f27b795429a31eabc4d2d9552f5a9c26096c253891afa4ff9ff77facf9b" }, "problem": { "code": "work-item.superseded", @@ -1942,7 +1942,7 @@ "description": "Base Registry Engine project authoring JSON Schema.", "source": { "path": "crates/registry-breg/src/schema.rs", - "sha256": "c6ed94a5503a4c6a7c5426add44846346873132065200dd09525b6b0cd2fb8ee" + "sha256": "6b7249e0065057095a429035151c6ec3b4656e40ac4ccabbeb5500547a6b7257" }, "artifact": { "path": "products/breg/generated/authoring/registry-module.schema.json", @@ -1960,11 +1960,11 @@ "description": "Base Registry Engine project authoring JSON Schema.", "source": { "path": "crates/registry-breg/src/schema.rs", - "sha256": "c6ed94a5503a4c6a7c5426add44846346873132065200dd09525b6b0cd2fb8ee" + "sha256": "6b7249e0065057095a429035151c6ec3b4656e40ac4ccabbeb5500547a6b7257" }, "artifact": { "path": "products/breg/generated/authoring/registry-project.schema.json", - "sha256": "3788dc0525c7d88db26dbba41d00eb8fa4932c6e5b6801895117a5bb1edbebb4", + "sha256": "a1eaee5e22565d743e7bae1dcddc517feecdedc5f93925e59ee1d9a4f3802385", "mediaType": "application/schema+json" } }, @@ -1978,11 +1978,11 @@ "description": "Base Registry Engine runtime configuration JSON Schema.", "source": { "path": "crates/registry-breg/src/schema.rs", - "sha256": "c6ed94a5503a4c6a7c5426add44846346873132065200dd09525b6b0cd2fb8ee" + "sha256": "6b7249e0065057095a429035151c6ec3b4656e40ac4ccabbeb5500547a6b7257" }, "artifact": { "path": "products/breg/generated/runtime/runtime.schema.json", - "sha256": "c333f8498efb907af59b4ec7d4ae747c4b50beff3323e33920f9184fe8281c79", + "sha256": "f5de662254f6aa4f7fe24b687575ea0a430f8e7ef31b430e448f4ba83869b05d", "mediaType": "application/schema+json" } }, @@ -1996,11 +1996,11 @@ "description": "Registry Casework runtime configuration JSON Schema.", "source": { "path": "crates/registry-casework/src/schema.rs", - "sha256": "0f45eaa62a216138216b5d230361cd324bd6384140f60e590bd2ce10b272a803" + "sha256": "1b98253897a283b29ee9f62f59f62c6781a245b658d833ab8833f54b0d5adfd6" }, "artifact": { "path": "products/casework/generated/runtime/runtime.schema.json", - "sha256": "f46bcf76ffc82242a921f753402f4f4ae3a190140ee09a85a040abea296e39af", + "sha256": "7a302d29e41b93aac96b2b30e896b62f69ee36277cd3a4ea8327e7184b1ceeac", "mediaType": "application/schema+json" } }, diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index 6a62d6a7cc..37c5b5d3be 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -52,7 +52,8 @@ def test_advisory_bootstrap_cannot_qualify_as_normal_rehearsal(self) -> None: index for index, step in enumerate(steps) if step.get("name") == "Setup Node" )) - env = {**os.environ, "REHEARSAL_VERSION": "0.30.0"} + # Exercise the current post-Mint inventory, not published v0.30.0. + env = {**os.environ, "REHEARSAL_VERSION": "0.30.1"} for advisory, code in (("false", 1), ("true", 0), ("invalid", 2)): with self.subTest(advisory=advisory): result = subprocess.run( From 11a549c5716c6ceab19c6a182c43e02e179e503f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:23:10 +0700 Subject: [PATCH 086/120] fix(identity): preserve bind mount ownership Signed-off-by: Jeremi Joslin --- .../src/bootstrap.rs | 4 ++ .../src/container.rs | 58 +++++++++++++++++-- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/registry-thunderid-tooling/src/bootstrap.rs b/crates/registry-thunderid-tooling/src/bootstrap.rs index 62f20d03f1..28ce6bad48 100644 --- a/crates/registry-thunderid-tooling/src/bootstrap.rs +++ b/crates/registry-thunderid-tooling/src/bootstrap.rs @@ -67,6 +67,8 @@ impl Bootstrap<'_> { let mut args: Vec = vec![ "run".into(), "--rm".into(), + "--user".into(), + crate::container::bind_mount_user(), "--entrypoint".into(), "./thunderid".into(), ]; @@ -258,6 +260,8 @@ mod tests { // The entrypoint is the upstream binary, so the container runs no // shell and no server. assert!(args.contains(&"./thunderid".to_owned())); + let owner = crate::container::bind_mount_user(); + assert!(args.windows(2).any(|pair| pair == ["--user", &owner])); assert!(!args.iter().any(|argument| argument == "serve")); runner.success = false; diff --git a/crates/registry-thunderid-tooling/src/container.rs b/crates/registry-thunderid-tooling/src/container.rs index 7e2d0e2f75..b12e1d4b95 100644 --- a/crates/registry-thunderid-tooling/src/container.rs +++ b/crates/registry-thunderid-tooling/src/container.rs @@ -103,9 +103,10 @@ impl Session<'_> { // Seed the shipped schema into a fresh session's database directory. // The image's databases carry the table layout setup.sh's bootstrap // writes into; an empty directory has no tables and setup cannot - // create them. The copy runs as root inside the container only to - // hand ownership to the image's non-root user, exactly as upstream - // deployment guidance does for a fresh volume. + // create them. Run bind-mounted lifecycle steps as the invoking host + // user. Linux Docker Engine preserves host ownership, so the image's + // fixed uid cannot otherwise write these directories. This also keeps + // retained private state owned by the caller. let database_is_empty = std::fs::read_dir(&database) .map(|entries| entries.count() == 0) .unwrap_or(false); @@ -114,14 +115,14 @@ impl Session<'_> { "run".into(), "--rm".into(), "--user".into(), - "0:0".into(), + bind_mount_user(), "--mount".into(), format!("type=bind,src={},dst=/seed", database.display()), "--entrypoint".into(), "sh".to_owned(), self.image.to_owned(), "-c".to_owned(), - "cp -r /opt/thunderid/database/. /seed/ && chown -R 10001:10001 /seed".to_owned(), + "cp -r /opt/thunderid/database/. /seed/".to_owned(), ]; let outcome = runner.run("docker", &seed, &[])?; if !outcome.success { @@ -131,7 +132,12 @@ impl Session<'_> { } } - let mut args: Vec = vec!["run".into(), "--rm".into()]; + let mut args: Vec = vec![ + "run".into(), + "--rm".into(), + "--user".into(), + bind_mount_user(), + ]; for (host, container) in self.setup_mounts() { args.push("--mount".into()); args.push(format!( @@ -266,6 +272,8 @@ impl Session<'_> { let mut args: Vec = vec![ "run".into(), "-d".into(), + "--user".into(), + bind_mount_user(), "--name".into(), name.clone(), "--label".into(), @@ -394,6 +402,14 @@ passkey: ) } +pub(crate) fn bind_mount_user() -> String { + format!( + "{}:{}", + rustix::process::geteuid().as_raw(), + rustix::process::getegid().as_raw() + ) +} + pub(crate) fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), ToolingError> { use std::os::unix::fs::PermissionsExt; if path.exists() { @@ -570,4 +586,34 @@ mod tests { ); std::fs::remove_dir_all(root).unwrap(); } + + #[test] + fn fresh_prepare_runs_every_bind_mount_as_the_host_owner() { + let root = std::env::temp_dir().join(format!( + "thunderid-bind-owner-test-{}-{}", + std::process::id(), + random_urlsafe(8).unwrap() + )); + std::fs::create_dir(&root).unwrap(); + let session = Session { + label: "owned-test", + id: "0197aaaa-0000-7000-8000-0000000000a1", + port: 18091, + state_root: &root, + image: "pinned-test-image", + }; + session.save_state(&SessionState::default()).unwrap(); + let mut runner = Runner::default(); + + session.prepare(&mut runner).unwrap(); + + assert_eq!(runner.commands.len(), 2); + let owner = bind_mount_user(); + for command in &runner.commands { + assert!(command.windows(2).any(|pair| pair == ["--user", &owner])); + } + assert!(!runner.commands[0].join(" ").contains("chown")); + assert!(session.load_state().unwrap().setup_complete); + std::fs::remove_dir_all(root).unwrap(); + } } From 093bc1a8991a3778bcc9c11cea54f18f6da60896 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:23:16 +0700 Subject: [PATCH 087/120] fix(breg): preserve submitter target authorization proof Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/compiler.rs | 2 +- .../tests/support/submitter_targets.rs | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/registry-breg/src/compiler.rs b/crates/registry-breg/src/compiler.rs index d5aa93873c..15f57b9de0 100644 --- a/crates/registry-breg/src/compiler.rs +++ b/crates/registry-breg/src/compiler.rs @@ -1608,7 +1608,7 @@ fn expand_project_access( "a requester client must be a bounded non-whitespace identifier", )); } - if profile.actor_kind.is_some() != !profile.requester_clients.is_empty() { + if profile.actor_kind.is_some() == profile.requester_clients.is_empty() { errors.push(Diagnostic::error( "access_profile.actor_client.binding_required", "project.accessProfiles[]", diff --git a/crates/registry-breg/tests/support/submitter_targets.rs b/crates/registry-breg/tests/support/submitter_targets.rs index 4d7a158070..ea14d24650 100644 --- a/crates/registry-breg/tests/support/submitter_targets.rs +++ b/crates/registry-breg/tests/support/submitter_targets.rs @@ -4,11 +4,30 @@ fn quote(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } -fn starter() -> registry_breg::CompiledRegistry { - let project = parse_project_json(include_bytes!( +fn starter_source() -> Value { + let mut source: Value = serde_json::from_slice(include_bytes!( "../../../../products/breg/starters/professional-licences/core/registry.yaml" )) .expect("starter parses"); + let reviewer = source["accessProfiles"] + .as_array_mut() + .expect("starter access profiles") + .iter_mut() + .find(|profile| profile["id"] == "reviewer") + .expect("starter reviewer profile") + .as_object_mut() + .expect("reviewer profile is an object"); + // This module injects claims after token verification. Contextual actor and + // client admission has its own authentication tests; these cases isolate + // current target predicates, locks, replay, and request atomicity. + reviewer.remove("actorKind"); + reviewer.remove("requesterClients"); + source +} + +fn starter() -> registry_breg::CompiledRegistry { + let project = parse_project_json(&serde_json::to_vec(&starter_source()).unwrap()) + .expect("starter parses"); compile_project(&project, &[], CompileProfile::Authoring) .expect("holder target admission compiles") } @@ -332,10 +351,7 @@ async fn native_reference_submitter_admission_is_live_and_atomic() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn review_snapshots_require_current_target_authority() { - let mut source: Value = serde_json::from_slice(include_bytes!( - "../../../../products/breg/starters/professional-licences/core/registry.yaml" - )) - .unwrap(); + let mut source = starter_source(); let reviewer = source["accessProfiles"] .as_array_mut() .unwrap() From e8e8aa6cc7be2dca42fdbb1ba5b30c73a708ad97 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:24:43 +0700 Subject: [PATCH 088/120] fix(clients): complete task facade and group audit identity Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/runtime.rs | 99 ++++++++++++++++--------- crates/registry-stack-client/src/lib.rs | 6 +- 2 files changed, 67 insertions(+), 38 deletions(-) diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index db653d1e03..e6e2d6efca 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -1110,10 +1110,12 @@ impl EvidenceRuntime { Err(AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority) => { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1133,10 +1135,12 @@ impl EvidenceRuntime { { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1162,10 +1166,12 @@ impl EvidenceRuntime { Err(AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority) => { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1544,10 +1550,12 @@ impl EvidenceRuntime { Err(AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority) => { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1568,10 +1576,12 @@ impl EvidenceRuntime { { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1623,10 +1633,12 @@ impl EvidenceRuntime { Err(AuthorizationError::Unauthorized | AuthorizationError::AmbiguousAuthority) => { self.append_authorization_refusal( operation, - refusal_requester_pseudonym, - refusal_actor_pseudonym, - context.actor_kind(), - refusal_client_pseudonym, + AuditCaller { + requester_pseudonym: refusal_requester_pseudonym, + actor_pseudonym: refusal_actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym: refusal_client_pseudonym, + }, refusal_grant_pseudonym, started, ) @@ -1683,10 +1695,12 @@ impl EvidenceRuntime { .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; let material = self.audit_material( &issuance_scope, - requester_pseudonym, - actor_pseudonym, - context.actor_kind(), - client_pseudonym, + AuditCaller { + requester_pseudonym, + actor_pseudonym, + actor_kind: context.actor_kind(), + client_pseudonym, + }, &resolved, format, )?; @@ -3172,13 +3186,16 @@ impl EvidenceRuntime { fn audit_material( &self, scope: &str, - requester_pseudonym: String, - actor_pseudonym: Option, - actor_kind: ActorKind, - client_pseudonym: Option, + caller: AuditCaller, resolved: &ResolvedAuthorization, format: ResponseFormat, ) -> Result { + let AuditCaller { + requester_pseudonym, + actor_pseudonym, + actor_kind, + client_pseudonym, + } = caller; let grant_pseudonym = resolved .grant_id .as_deref() @@ -3224,13 +3241,16 @@ impl EvidenceRuntime { async fn append_authorization_refusal( &self, operation: &str, - requester_pseudonym: String, - actor_pseudonym: Option, - actor_kind: ActorKind, - client_pseudonym: Option, + caller: AuditCaller, grant_pseudonym: Option, started: Instant, ) -> Result<(), RuntimeFailure> { + let AuditCaller { + requester_pseudonym, + actor_pseudonym, + actor_kind, + client_pseudonym, + } = caller; let mut event = EvidenceAuthorizationRefusalAuditEvent::new( self.bundle().config.assurance_profile, operation.to_owned(), @@ -3456,6 +3476,13 @@ enum SourceStageLookup { DeclaredUnresolved, } +struct AuditCaller { + requester_pseudonym: String, + actor_pseudonym: Option, + actor_kind: ActorKind, + client_pseudonym: Option, +} + struct AuditMaterial { assurance_profile: AssuranceProfile, requirement: String, diff --git a/crates/registry-stack-client/src/lib.rs b/crates/registry-stack-client/src/lib.rs index 6aaba65044..17320e09e2 100644 --- a/crates/registry-stack-client/src/lib.rs +++ b/crates/registry-stack-client/src/lib.rs @@ -47,8 +47,10 @@ pub mod casework { ReleaseRequest, RequesterHostedItem, RoutingActivity, RoutingCondition, RoutingPredicate, RoutingRule, SaveDraftRequest, SourceBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, SubjectClockCompletion, - SubjectClockPause, SubjectRef, TeamRecord, Uuid, WorkItem, WorkItemPage, WorkItemRouting, - WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, + SubjectClockPause, SubjectRef, TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, + TaskGrantList, TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + TaskPermission, TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, + WorkItemPage, WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, }; } From eedad34053740bc783a4f315386848c13c78c608 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:25:19 +0700 Subject: [PATCH 089/120] test(relay): update Python auth config drift Signed-off-by: Jeremi Joslin --- crates/registry-relay-client-py/README.md | 12 +++++++----- .../tests/python/test_drift.py | 2 ++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/registry-relay-client-py/README.md b/crates/registry-relay-client-py/README.md index 75efdfd4f6..ad71025ad9 100644 --- a/crates/registry-relay-client-py/README.md +++ b/crates/registry-relay-client-py/README.md @@ -60,11 +60,13 @@ connection. A token endpoint using a private CA needs its own byte-valued `trusted_root_certificates` inside `private_key_jwt`; the two trust inputs are deliberately independent. -The built-in private-key-JWT flow sends only `grant_type`, -`client_assertion_type`, and `client_assertion`. It does not send `scope`, -`resource`, a body `client_id`, or deployment-defined form members. When an -issuer requires any of those fields, acquire a short-lived bearer separately -and pass it through `authorization={"static": token}`. +The built-in private-key-JWT flow sends `grant_type`, a body `client_id`, +`client_assertion_type`, and `client_assertion`. Optional `resource` and +`scopes` members add the RFC 8707 `resource` parameter and the space-delimited +OAuth `scope` parameter respectively; omitting them sends neither parameter. +The flow accepts no deployment-defined form members. When an issuer requires +other fields, acquire a short-lived bearer separately and pass it through +`authorization={"static": token}`. Every method is blocking and releases the Python GIL while the private current-thread Tokio runtime waits for I/O. Conditional methods return a plain diff --git a/crates/registry-relay-client-py/tests/python/test_drift.py b/crates/registry-relay-client-py/tests/python/test_drift.py index f47ff3c25c..cf6a96cc36 100644 --- a/crates/registry-relay-client-py/tests/python/test_drift.py +++ b/crates/registry-relay-client-py/tests/python/test_drift.py @@ -173,6 +173,8 @@ def test_required_and_optional_typed_dict_keys_are_pinned(self): class_members(private_config), { "audience", + "resource", + "scopes", "assertion_lifetime_seconds", "refresh_margin_seconds", "request_timeout_seconds", From 073b4b268670232cded9cf31831b4417b993e382 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:26:30 +0700 Subject: [PATCH 090/120] fix(identity): bound private state permissions Signed-off-by: Jeremi Joslin --- .../registry-thunderid-tooling/src/render.rs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/registry-thunderid-tooling/src/render.rs b/crates/registry-thunderid-tooling/src/render.rs index 8882884119..58dee4b6f9 100644 --- a/crates/registry-thunderid-tooling/src/render.rs +++ b/crates/registry-thunderid-tooling/src/render.rs @@ -99,6 +99,25 @@ pub(crate) fn write_owner_only(path: &Path, bytes: &[u8]) -> Result<(), ToolingE Ok(()) } +fn prepare_private_root(root: &Path) -> Result<(), ToolingError> { + fs::create_dir_all(root).map_err(|_| ToolingError::Filesystem { + reason: "the state directory could not be created", + })?; + let metadata = fs::symlink_metadata(root).map_err(|_| ToolingError::Filesystem { + reason: "the state directory could not be inspected", + })?; + if !metadata.file_type().is_dir() { + return Err(ToolingError::Filesystem { + reason: "the state directory must be an ordinary directory", + }); + } + fs::set_permissions(root, fs::Permissions::from_mode(0o700)).map_err(|_| { + ToolingError::Filesystem { + reason: "the state directory could not be made owner-only", + } + }) +} + /// Serialize one upstream document as YAML, with the `---` document start the /// upstream loader's multi-document files carry. fn yaml_document(value: &Value) -> Result { @@ -163,6 +182,10 @@ fn serde_yaml_parse(text: &str) -> Result { /// one-document directory for the bootstrap one-shot. pub fn render(description: &IssuerDescription) -> Result { description.validate()?; + // Establish the caller-declared ownership boundary before creating any + // descendants. `write_owner_only` then stops its permission walk here and + // never attempts to change a shared ancestor such as `/tmp`. + prepare_private_root(&description.state_root)?; let root = description.state_root.join(RESOURCES_DIR); let bootstrap_root = description.state_root.join(BOOTSTRAP_DIR); @@ -526,6 +549,46 @@ mod tests { ); assert_eq!(role["assignments"][0]["type"], json!("agent")); } + + #[test] + fn rendering_keeps_permissions_inside_the_declared_state_root() { + let parent = std::env::temp_dir().join(format!( + "registry-thunderid-tooling-parent-{}", + crate::container::random_urlsafe(16).unwrap() + )); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap(); + + let mut description = crate::testing::synthetic_description(); + description.state_root = parent.join("state"); + fs::create_dir_all(description.state_root.join("secrets")).unwrap(); + fs::write( + description + .state_root + .join("secrets/compatibility-client-secret"), + "fixture-secret-not-a-real-credential", + ) + .unwrap(); + + render(&description).expect("the description renders"); + assert_eq!( + fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o755, + "rendering must not chmod an ancestor outside the state root" + ); + assert_eq!( + fs::metadata(&description.state_root) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700, + "the declared state root must be owner-only" + ); + + fs::remove_dir_all(parent).unwrap(); + } + #[test] fn exchange_uses_native_user_config_and_unconditional_verified_issuer_mapping() { use crate::description::{ExchangeIssuer, TokenExchangeClient, GRANT_ATTRIBUTES}; From ca0f3fbabae845d8cec10fc68f19d723d2d6fa5f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:35:35 +0700 Subject: [PATCH 091/120] test(release): use post-retirement rehearsal inventory Signed-off-by: Jeremi Joslin --- release/scripts/test_collect_rehearsal_advisory_evidence.py | 4 ++-- release/scripts/test_zig_glibc_compiler.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/release/scripts/test_collect_rehearsal_advisory_evidence.py b/release/scripts/test_collect_rehearsal_advisory_evidence.py index 30892eb5d9..c7df89ac61 100644 --- a/release/scripts/test_collect_rehearsal_advisory_evidence.py +++ b/release/scripts/test_collect_rehearsal_advisory_evidence.py @@ -196,14 +196,14 @@ def test_retired_mint_is_not_an_admitted_rehearsal_image(self) -> None: with self.assertRaises(MODULE.EvidenceError): MODULE.parse_roster("evidence mint relay\n") - def test_v0_30_roster_is_owned_and_complete(self) -> None: + def test_post_mint_roster_is_owned_and_complete(self) -> None: result = subprocess.run( [ "python3", str(ROOT / "release/scripts/release_candidate.py"), "image-names", "--version", - "0.30.0", + "0.30.1", ], check=True, capture_output=True, diff --git a/release/scripts/test_zig_glibc_compiler.py b/release/scripts/test_zig_glibc_compiler.py index fe16b45265..216a14b936 100644 --- a/release/scripts/test_zig_glibc_compiler.py +++ b/release/scripts/test_zig_glibc_compiler.py @@ -558,7 +558,7 @@ def test_pre_breg_empty_producer_merges_with_the_core_shard(self) -> None: def test_merged_groups_are_byte_mode_and_inventory_equivalent_to_all(self) -> None: source_sha = "1" * 40 - def build(group: str, version: str = "0.30.0") -> subprocess.CompletedProcess[str]: + def build(group: str, version: str = "0.30.1") -> subprocess.CompletedProcess[str]: arguments = ["bash", str(self.scripts / BINARY_RECIPE.name)] if group != "all": arguments.extend(["--group", group]) @@ -598,7 +598,7 @@ def build(group: str, version: str = "0.30.0") -> subprocess.CompletedProcess[st [ str(self.scripts / "merge-release-binary-shards.py"), "--version", - "0.30.0", + "0.30.1", "--source-sha", source_sha, "--core", From d384fc5712c30f1265501a4f4aa5d358b6856a65 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:35:36 +0700 Subject: [PATCH 092/120] test(casework): simplify retired installer fixture Signed-off-by: Jeremi Joslin --- .../registry-casework/tests/install_script.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/registry-casework/tests/install_script.rs b/crates/registry-casework/tests/install_script.rs index ebb8df5cd1..e4e6f68b75 100644 --- a/crates/registry-casework/tests/install_script.rs +++ b/crates/registry-casework/tests/install_script.rs @@ -405,16 +405,15 @@ exec /bin/mv "${arguments[@]}" fn preinstall_pointer_toolset_without_ctl(&self) { let toolset = self.install_dir.join(".casework-toolset.earlier"); fs::create_dir_all(&toolset).unwrap(); - for binary in ["casework"] { - let path = toolset.join(binary); - fs::write(&path, format!("{binary} previous binary\n")).unwrap(); - fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); - std::os::unix::fs::symlink( - format!(".casework-current/{binary}"), - self.install_dir.join(binary), - ) - .unwrap(); - } + let binary = "casework"; + let path = toolset.join(binary); + fs::write(&path, format!("{binary} previous binary\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + std::os::unix::fs::symlink( + format!(".casework-current/{binary}"), + self.install_dir.join(binary), + ) + .unwrap(); std::os::unix::fs::symlink( ".casework-toolset.earlier", self.install_dir.join(".casework-current"), From 74213f2717e218b7442119c6bfe32a212798d3d7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:39:33 +0700 Subject: [PATCH 093/120] docs(evidence): update OID4VCI token client key Signed-off-by: Jeremi Joslin --- .../docs/tutorials/run-oid4vci-interoperability-checks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx b/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx index bac2999bb5..5c043d29ec 100644 --- a/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx +++ b/docs/site/src/content/docs/tutorials/run-oid4vci-interoperability-checks.mdx @@ -67,7 +67,7 @@ metricsListener: port: 18441 evidence: baseUrl: http://127.0.0.1:18442 -mint: +tokenClient: tokenEndpoint: http://127.0.0.1:18442/token clientId: evidence-oid4vci-tutorial privateKeyFile: delivery-client.jwk.json From fbfda4f24c1280ecce91768c721ac825985fec70 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:43:15 +0700 Subject: [PATCH 094/120] ci(breg): serialize retained lifecycle sessions Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff21da0b1c..b933f9489f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1254,11 +1254,11 @@ jobs: shell: bash run: | set -euo pipefail - # The retained lifecycle owns a PostgreSQL container of its own, so - # this test stays in the Docker-backed job. + # Each retained lifecycle owns PostgreSQL, ThunderID and BREG. + # Run complete sessions serially on the bounded CI runner. CARGO_TARGET_DIR="target/breg-tutorial-source" \ cargo test --locked --profile ci -p registry-bregctl \ - --test dev_lifecycle -- --ignored + --test dev_lifecycle -- --ignored --test-threads=1 - name: Verify authored request attachment lifecycle run: >- From 6c4c87cac7363f73a8d058c768bbcb99253b5366 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 04:46:36 +0700 Subject: [PATCH 095/120] test(breg): preserve starter-specific actor admission Signed-off-by: Jeremi Joslin --- .../tests/support/starter_policy.rs | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/registry-breg/tests/support/starter_policy.rs b/crates/registry-breg/tests/support/starter_policy.rs index 8129365c62..34aedc90a0 100644 --- a/crates/registry-breg/tests/support/starter_policy.rs +++ b/crates/registry-breg/tests/support/starter_policy.rs @@ -8,8 +8,10 @@ use axum::body::{to_bytes, Body}; use axum::http::{HeaderMap, Request, StatusCode}; use axum::Router; +use registry_breg::contract::{ActorKindSource, ProjectAccessProfileSource, RegistryProject}; use registry_breg::fixtures::ValidatedFixtureJourneys; use serde_json::{json, Value}; +use std::collections::BTreeSet; use tower::ServiceExt; use uuid::Uuid; @@ -128,6 +130,7 @@ async fn professional_licence_starter_policy_journeys_and_http_refusals() { async fn run_starter(starter: &Starter) { let project = parse_project_yaml(starter.project).expect("authored starter parses"); + let requester_clients = requester_clients(&project); let registry = compile_project(&project, &[], CompileProfile::Production) .expect("authored starter compiles without policy substitutions"); let fingerprint = measure_compiled_schema_fingerprint(®istry).await; @@ -136,7 +139,7 @@ async fn run_starter(starter: &Starter) { let suite = validate_fixture_journeys(bytes, ®istry).expect("starter suite preflights"); let package = starter_package(starter.project, bytes, &fingerprint); let database = TestDatabase::create(8).await; - let config_path = starter_runtime_config(&package, &database, &idp); + let config_path = starter_runtime_config(&package, &database, &idp, &requester_clients); let config = load_runtime_config(&config_path).expect("starter runtime config loads"); let prepared_database = prepare_schema_test_database_with_connection_configs_for_test( &config, @@ -151,7 +154,7 @@ async fn run_starter(starter: &Starter) { &config, &package.prepared, &suite, - authored_credentials(bytes, &suite, &idp), + authored_credentials(bytes, &suite, &idp, &requester_clients), ) .await .unwrap_or_else(|error| { @@ -175,6 +178,8 @@ async fn run_starter(starter: &Starter) { let http = StarterHttp { app: server.app(), idp: &idp, + profiles: &project.access_profiles, + requester_clients: requester_clients.clone(), }; assert_http_policy(starter, &http).await; drop(server); @@ -253,6 +258,7 @@ fn starter_runtime_config( package: &PackageFixture, database: &TestDatabase, idp: &MockIdp, + requester_clients: &BTreeSet, ) -> std::path::PathBuf { // Reuse this target's static-JWKS, bounded-pool, private-secret setup. Only // replace deployment identity with the actual authored local package. @@ -264,7 +270,9 @@ fn starter_runtime_config( config["identity"]["instanceId"] = json!(manifest.instance_id); config["identity"]["databaseId"] = json!(manifest.database_id); config["package"]["compilerSourceRevision"] = json!(manifest.compiler.source_revision); - config["authentication"]["oidc"]["allowedClients"] = json!(["staff", "supervisor"]); + if !requester_clients.is_empty() { + config["authentication"]["oidc"]["allowedClients"] = json!(requester_clients); + } config["authentication"]["authorityClaims"]["contextual"] = json!({ "actorKind": "registry_actor_kind", "purpose": "registry_purpose", @@ -285,6 +293,7 @@ fn authored_credentials( bytes: &[u8], suite: &ValidatedFixtureJourneys, idp: &MockIdp, + requester_clients: &BTreeSet, ) -> SchemaTestCredentialBindings { let source: Value = serde_norway::from_slice(bytes).expect("authored journeys parse"); let mut bindings = Vec::new(); @@ -300,11 +309,16 @@ fn authored_credentials( .join(" "); let mut token_claims = json!({ "aud": AUDIENCE, - "client_id": claims["requesterClient"].as_str().unwrap_or("staff"), "registry_principal": claims["principal"], "registry_purpose": claims["purpose"], "scope": scopes, }); + if let Some(client) = claims["requesterClient"] + .as_str() + .or_else(|| requester_clients.first().map(String::as_str)) + { + token_claims["client_id"] = json!(client); + } if !claims["actorKind"].is_null() { token_claims["registry_actor_kind"] = claims["actorKind"].clone(); } @@ -328,6 +342,8 @@ struct Actor<'a> { struct StarterHttp<'a> { app: Router, idp: &'a MockIdp, + profiles: &'a [ProjectAccessProfileSource], + requester_clients: BTreeSet, } impl StarterHttp<'_> { @@ -340,10 +356,25 @@ impl StarterHttp<'_> { body: Option, extra_headers: &[(&str, &str)], ) -> (StatusCode, Value, HeaderMap) { - let mut token_claims = json!({"aud": AUDIENCE, "client_id": "staff", "registry_principal": actor.principal, "registry_purpose": "starter-learning", "scope": actor.scope}); - if profile == "reviewer" { - token_claims["client_id"] = json!("supervisor"); - token_claims["registry_actor_kind"] = json!("human"); + let mut token_claims = json!({"aud": AUDIENCE, "registry_principal": actor.principal, "registry_purpose": "starter-learning", "scope": actor.scope}); + let authored_profile = self + .profiles + .iter() + .find(|candidate| candidate.id == profile) + .expect("HTTP fixture profile is authored"); + if let Some(client) = authored_profile + .requester_clients + .first() + .or_else(|| self.requester_clients.first()) + { + token_claims["client_id"] = json!(client); + } + if let Some(actor_kind) = authored_profile.actor_kind { + token_claims["registry_actor_kind"] = json!(match actor_kind { + ActorKindSource::Human => "human", + ActorKindSource::Agent => "agent", + ActorKindSource::Service => "service", + }); } let token = self.idp.mint_token(token_claims); let separator = if path.contains('?') { '&' } else { '?' }; @@ -433,6 +464,14 @@ impl StarterHttp<'_> { } } +fn requester_clients(project: &RegistryProject) -> BTreeSet { + project + .access_profiles + .iter() + .flat_map(|profile| profile.requester_clients.iter().cloned()) + .collect() +} + async fn selected_action( http: &StarterHttp<'_>, path: &str, From d7e47b348cf4b3e7fafd7f3f629f450a5f9dbc0e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 05:04:34 +0700 Subject: [PATCH 096/120] docs(evidence): align client tutorial with stock issuer Signed-off-by: Jeremi Joslin --- .../request-evidence-from-an-application.mdx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx index f65e5c45ea..e48fa143e5 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-from-an-application.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-19" +last_reviewed: "2026-09-13" doc_type: tutorial persona: - consumer or verifier @@ -214,11 +214,14 @@ client = EvidenceClient( revoked_key_ids=[], token={ "private_key_jwt": { - "token_endpoint": "http://127.0.0.1:8081/token", + "token_endpoint": "http://127.0.0.1:8081/oauth2/token", "client_id": "age-check-app", "client_key": json.loads( Path(".evidence/clients/age-check-app/private.jwk").read_text() ), + "audience": "http://127.0.0.1:8081", + "resource": "urn:registrystack:evidence:local:gateway", + "scopes": ["evidence:invoke"], }, }, ) @@ -486,11 +489,14 @@ def build_client(): revoked_key_ids=[], token={ "private_key_jwt": { - "token_endpoint": "http://127.0.0.1:8081/token", + "token_endpoint": "http://127.0.0.1:8081/oauth2/token", "client_id": "age-check-app", "client_key": json.loads( Path(".evidence/clients/age-check-app/private.jwk").read_text() ), + "audience": "http://127.0.0.1:8081", + "resource": "urn:registrystack:evidence:local:gateway", + "scopes": ["evidence:invoke"], }, }, ) From c3210bf4efc2e3d2bce22fa0a6cf1cc15c6ab07f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 05:23:29 +0700 Subject: [PATCH 097/120] fix(docs): replay revoked client refusal Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 10 +++++----- docs/site/scripts/check-evidence-tutorials.test.mjs | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 129c4da5fa..72fb601911 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -317,16 +317,16 @@ load_spec() { "run:Use the application assigned the policy" "run:Try a question the application was not granted" "run:Revoke an application|1" - "run-fails:Revoke an application|2" - "run:Revoke an application|3" + "run:Revoke an application|2" + "run-fails:Revoke an application|3" "run:Clean up" ) # This tutorial teaches refusal, so the refusals are what must hold. # The unauthorized request's curl carries no --fail-with-body, so it # exits zero on a 403 and a boundary that started answering 200 would - # leave the journey green. The revocation step requires a non-zero - # exit; the message is what proves it was refused because the - # client was revoked rather than for some unrelated reason. + # leave the journey green. The post-revocation preparation requires a + # non-zero exit; the message proves the client was revoked rather than + # refused for some unrelated reason. SPEC_ASSERTS=( "VERIFIED" "HTTP 403" diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs index 059bdeee47..82b7c435f7 100644 --- a/docs/site/scripts/check-evidence-tutorials.test.mjs +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -234,6 +234,10 @@ test('the caller-access replay expects the privacy-safe refusal audit line', asy /\n\tcontrol-who-can-request-evidence\)[\s\S]*?\n\t\t;;/u, )?.[0]; assert.ok(branch, 'the caller-access replay spec must exist'); + assert.match( + branch, + /"run:Revoke an application\|1"\s+"run:Revoke an application\|2"\s+"run-fails:Revoke an application\|3"/u, + ); assert.match(branch, /"ACCESS REFUSED requester="/u); assert.match(branch, /"reason=not_authorized"/u); assert.doesNotMatch(branch, /ACCESS AUTHORIZED age-bracket/u); From c29c2b03f9b2e1546c53c686b5dc216b8c2758cd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 05:33:39 +0700 Subject: [PATCH 098/120] fix(docs): stop final Evidence generation Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-evidence-tutorials.sh | 1 + docs/site/scripts/check-evidence-tutorials.test.mjs | 2 +- .../src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 72fb601911..14477c8cce 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -319,6 +319,7 @@ load_spec() { "run:Revoke an application|1" "run:Revoke an application|2" "run-fails:Revoke an application|3" + "run:Stop the final generation" "run:Clean up" ) # This tutorial teaches refusal, so the refusals are what must hold. diff --git a/docs/site/scripts/check-evidence-tutorials.test.mjs b/docs/site/scripts/check-evidence-tutorials.test.mjs index 82b7c435f7..d3fa31c3a9 100644 --- a/docs/site/scripts/check-evidence-tutorials.test.mjs +++ b/docs/site/scripts/check-evidence-tutorials.test.mjs @@ -236,7 +236,7 @@ test('the caller-access replay expects the privacy-safe refusal audit line', asy assert.ok(branch, 'the caller-access replay spec must exist'); assert.match( branch, - /"run:Revoke an application\|1"\s+"run:Revoke an application\|2"\s+"run-fails:Revoke an application\|3"/u, + /"run:Revoke an application\|1"\s+"run:Revoke an application\|2"\s+"run-fails:Revoke an application\|3"\s+"run:Stop the final generation"\s+"run:Clean up"/u, ); assert.match(branch, /"ACCESS REFUSED requester="/u); assert.match(branch, /"reason=not_authorized"/u); diff --git a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx index 2a7bac1ffa..e27a31d7b5 100644 --- a/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx +++ b/docs/site/src/content/docs/tutorials/issue-fhir-evidence-as-vcs.mdx @@ -5,7 +5,7 @@ status: current owner: registry-docs source_repos: - registry-stack -last_reviewed: "2026-08-19" +last_reviewed: "2026-09-13" doc_type: tutorial persona: - assertion provider @@ -623,7 +623,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` Evidence Gateway now reads the live SMART server through the loopback adapter. It fixes one GET From 374871e961725b99992eb1b2840fc062a761be3e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 09:48:11 +0700 Subject: [PATCH 099/120] chore: remove retired Mint adopter tooling and comments Signed-off-by: Jeremi Joslin --- crates/registry-bregctl/src/dev/tests.rs | 4 +- crates/registry-casework-core/src/config.rs | 2 +- .../src/authorizer.rs | 34 +- .../registry-evidence-oid4vci/src/config.rs | 16 +- crates/registry-evidence-oid4vci/src/lib.rs | 4 +- .../src/secretfile.rs | 6 +- crates/registry-evidence/src/config.rs | 12 +- crates/registry-evidence/src/secrets.rs | 4 +- docker/Dockerfile | 36 +- docker/README.md | 78 +-- docker/compose/README.md | 75 +-- docker/compose/docker-compose.mint.yaml | 54 -- docker/runtime-preflight.py | 240 +------- docker/test_runtime_preflight.py | 545 +----------------- .../evidence-production-build-docs.test.mjs | 1 - products/breg/README.md | 27 +- release/scripts/test_check_debian13_images.py | 10 +- release/security/mint-advisory-baseline.json | 303 ---------- 18 files changed, 109 insertions(+), 1342 deletions(-) delete mode 100644 docker/compose/docker-compose.mint.yaml delete mode 100644 release/security/mint-advisory-baseline.json diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index f0f38f8274..b593c72896 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -180,8 +180,8 @@ fn clients_require_explicit_unique_profile_bindings_and_closed_fields() { } /// A client may bind no access profile. Such a client still needs its own -/// unique ID and explicit scopes; it registers with Mint but is excluded from -/// BReg's `allowedClients`, and no journey or seed can resolve it. +/// unique ID and explicit scopes; it is provisioned at the dev token issuer but +/// excluded from BReg's `allowedClients`, and no journey or seed can resolve it. #[test] fn clients_accept_an_explicitly_unbound_profile_free_client() { let clients = config::clients( diff --git a/crates/registry-casework-core/src/config.rs b/crates/registry-casework-core/src/config.rs index 6e8d3b518b..7a788d6595 100644 --- a/crates/registry-casework-core/src/config.rs +++ b/crates/registry-casework-core/src/config.rs @@ -10,7 +10,7 @@ use crate::{check_clock_policies, check_routing_policy, CalendarPolicy, ClockPol pub const CASEWORK_API_VERSION: &str = "registry.registrystack.org/casework/v1alpha1"; pub const CASEWORK_KIND: &str = "CaseworkProject"; -// Matches the maintained Mint issuer's bounded RFC 6749 scope-token contract. +// Matches the maximum RFC 6749 scope-token size Casework accepts. const MAXIMUM_REQUIRED_SCOPE_BYTES: usize = 256; fn default_page_size() -> usize { diff --git a/crates/registry-evidence-oid4vci/src/authorizer.rs b/crates/registry-evidence-oid4vci/src/authorizer.rs index 8023cf636b..2fbdf99cfd 100644 --- a/crates/registry-evidence-oid4vci/src/authorizer.rs +++ b/crates/registry-evidence-oid4vci/src/authorizer.rs @@ -1,17 +1,19 @@ //! The authorization boundary of the adopter-facing offer endpoint. //! -//! This is the resource-server half of the process. It verifies a Mint-issued -//! access token through `registry-platform-oidc`, on the same strict profile -//! Evidence's own authenticator builds: an exact issuer, a closed audience -//! list, a closed algorithm list, a closed access-token `typ` list, a ceiling -//! on token lifetime, and keys resolved only through the configured key set. +//! This is the resource-server half of the process. It verifies an access token +//! from the configured issuer through `registry-platform-oidc`, on the strict +//! profile Evidence's own authenticator builds: an exact issuer, a closed +//! audience list, a closed algorithm list, a closed access-token `typ` list, a +//! ceiling on token lifetime, and keys resolved only through the configured key +//! set. //! -//! The client half of the process, which authenticates *to* Mint with this -//! service's own private key, is [`crate::issuer`]. The two never share a code -//! path: nothing here reads the client key, nothing here is derived from the -//! client identity, and the two are configured by separate documents. A -//! deployment whose client key is unusable still authorizes offers, and a -//! deployment whose offer issuer is unreachable still requests credentials. +//! The client half of the process, which authenticates to the configured token +//! endpoint with this service's own private key, is [`crate::issuer`]. The two +//! never share a code path: nothing here reads the client key, nothing here is +//! derived from the client identity, and the two are configured by separate +//! documents. A deployment whose client key is unusable still authorizes +//! offers, and a deployment whose offer issuer is unreachable still requests +//! credentials. use std::{collections::HashSet, sync::Arc, time::Duration}; @@ -62,7 +64,7 @@ pub trait OfferAuthorizer: Send + Sync { async fn authorize(&self, credential: &str) -> Result; } -/// The Mint-issued access token verifier. +/// The configured issuer's access-token verifier. #[derive(Debug)] pub struct MintResourceServer { verifier: Arc, @@ -75,8 +77,8 @@ pub struct MintResourceServer { impl MintResourceServer { /// Build the resource server from its own configuration document. /// - /// Nothing about the client identity this service authenticates to Mint - /// with is read here, on purpose: the offer boundary must be configurable, + /// Nothing about the outbound token-client identity is read here, on + /// purpose: the offer boundary must be configurable, /// and auditable, without reference to who this service is elsewhere. #[must_use] pub fn from_config(config: &OfferAuthorizationConfig, mode: ValidationMode) -> Self { @@ -276,8 +278,8 @@ mod tests { #[test] fn the_resource_server_is_built_from_the_offer_document_alone() { // The construction takes the offer boundary and the validation mode. - // There is no parameter for the Mint client identity, so no key or - // identifier belonging to the client half can reach this one. + // There is no parameter for the outbound token-client identity, so no + // key or identifier belonging to the client half can reach this one. let config = valid_config(); let _server = MintResourceServer::from_config(&config.offers, config.validation_mode); } diff --git a/crates/registry-evidence-oid4vci/src/config.rs b/crates/registry-evidence-oid4vci/src/config.rs index f90d264c51..8d404a9f0e 100644 --- a/crates/registry-evidence-oid4vci/src/config.rs +++ b/crates/registry-evidence-oid4vci/src/config.rs @@ -34,9 +34,9 @@ pub enum ConfigError { /// The transport validation boundary selected for this process. /// -/// The same two-axis vocabulary Mint uses, and deliberately no third assurance -/// concept: a delivery front end that graded itself on its own scale would be -/// inventing a security property nothing else in the stack recognizes. +/// A two-axis transport vocabulary with deliberately no third assurance +/// concept: a delivery front end that graded itself on its own scale would +/// invent a security property nothing else in the stack recognizes. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum ValidationMode { @@ -139,7 +139,8 @@ impl ListenerConfig { /// /// A zero body limit or a zero timeout leaves the service reporting itself /// ready while every request fails, which is an outage no probe can see. - /// The bounds match the Mint and Evidence listeners. + /// The bounds keep an unusable or excessively permissive listener from + /// passing startup validation. fn validate(&self) -> Result<(), ConfigError> { if self.port == 0 { return Err(ConfigError::Invalid( @@ -719,8 +720,7 @@ fn validate_supervised_local_endpoint(value: &str) -> Result<(), ConfigError> { /// /// Exact reconstruction rejects URL-parser aliases such as a trailing slash, /// leading-zero port, alternate IPv4 spelling, credentials, query, or fragment. -/// The same reasoning Mint applies to its own issuer: this string is compared, -/// not merely resolved. +/// The configured issuer identity is compared exactly, not merely resolved. fn parse_canonical_supervised_local_origin(value: &str) -> Result { let port = value .strip_prefix("http://127.0.0.1:") @@ -917,8 +917,8 @@ store: assert_eq!(config.offers.algorithms, [AccessTokenAlgorithm::EdDSA]); assert_eq!(config.offers.authorized_clients, ["adopter-front-end"]); assert_eq!(config.offers.maximum_token_lifetime_seconds, 900); - // Nothing in the offer boundary is derived from the Mint client - // identity this service authenticates with. + // Nothing in the offer boundary is derived from the outbound + // token-client identity this service authenticates with. assert_ne!(config.offers.issuer, config.token_client.token_endpoint); } diff --git a/crates/registry-evidence-oid4vci/src/lib.rs b/crates/registry-evidence-oid4vci/src/lib.rs index b4112157b2..ad90901c5b 100644 --- a/crates/registry-evidence-oid4vci/src/lib.rs +++ b/crates/registry-evidence-oid4vci/src/lib.rs @@ -4,8 +4,8 @@ //! to an authoritative source. It deliberately speaks no wallet protocol. A //! stock holder wallet, on the other hand, accepts a credential over exactly one //! protocol, so something has to speak it. This crate is that something: a -//! delivery front end that runs beside Evidence the way Registry Mint runs -//! beside it, as a supporting service rather than a third pattern. +//! delivery front end that runs beside Evidence as a supporting service rather +//! than a separate runtime product. //! //! Three properties define the service, and none of them is negotiable. //! diff --git a/crates/registry-evidence-oid4vci/src/secretfile.rs b/crates/registry-evidence-oid4vci/src/secretfile.rs index 301288f036..96e55bd9e0 100644 --- a/crates/registry-evidence-oid4vci/src/secretfile.rs +++ b/crates/registry-evidence-oid4vci/src/secretfile.rs @@ -1,9 +1,9 @@ //! Bounded, owner-only reads of the client key this service authenticates with. //! //! The service holds exactly one piece of private material: the key that signs -//! its client assertion to Mint. It holds no Evidence signing key and no holder -//! key, so this module is the whole private-material read boundary and is -//! deliberately small. +//! its private-key-JWT client assertion for the configured token endpoint. It +//! holds no Evidence signing key and no holder key, so this module is the whole +//! private-material read boundary and is deliberately small. use std::{fs, io::Read, os::unix::fs::MetadataExt, path::Path}; diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index a5b97b105f..862f70cf3c 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -1814,8 +1814,8 @@ impl AuthenticationConfig { /// Registered JWT claims no authority claim may be read from. `sub` is handled /// separately, because the principal claim may legitimately name it. /// -/// Mint refuses to write these when it mints. Evidence refuses to read them, -/// which is the check that still applies when the issuer is not Mint. +/// Evidence refuses to read these as authority regardless of what the +/// configured issuer writes. /// /// `cnf` is reserved for a second reason: the authenticator denies any token /// carrying it, because Version 1 validates no proof of possession and will not @@ -5969,7 +5969,7 @@ mod tests { config .validate() .expect("local profile accepts the supervised loopback identity"); - // The JWKS path is the issuer's to choose now, not Mint's fixed route. + // The JWKS path is the configured issuer's to choose. config.authentication.jwks_uri = "http://127.0.0.1:8081/oauth2/jwks".to_owned(); config .validate() @@ -6307,10 +6307,8 @@ mod tests { /// Two authority claims naming one JWT member, or naming a member the token /// already defines, is a configuration the verifier must refuse. /// - /// Mint refuses the same shapes when it mints (`ClaimNames::validate`), but - /// Mint is one possible issuer. Evidence is documented against any OIDC - /// issuer, and no other issuer enforces Mint's rules, so the deployment with - /// no issuer-side check is exactly the one where this is the only check. + /// Evidence accepts any configured OIDC issuer, so it enforces this rule at + /// its own resource-server boundary without assuming an issuer-side check. /// A grant authority claim named `aud` would read Evidence's own audience /// as the authority that granted the request. #[test] diff --git a/crates/registry-evidence/src/secrets.rs b/crates/registry-evidence/src/secrets.rs index 796391ebeb..e806b932a8 100644 --- a/crates/registry-evidence/src/secrets.rs +++ b/crates/registry-evidence/src/secrets.rs @@ -1,7 +1,7 @@ //! Shared bounded secret resolution used by Evidence. //! -//! The implementation lives in `registry-platform-config` so Evidence and -//! Mint apply the same anchored, no-follow, owner-only file policy. +//! The implementation lives in `registry-platform-config` so Evidence applies +//! the shared anchored, no-follow, owner-only file policy. pub use registry_platform_config::{ ProtectedSecret, SecretError, SecretProvider, SecretResolver, MAX_SECRET_BYTES, diff --git a/docker/Dockerfile b/docker/Dockerfile index 7014d27d6d..9668c32340 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 -# Adopter and development images for the `mint`, `evidence`, and `casework` services. +# Adopter and development images for the `evidence` and `casework` services. # # These are not release evidence. Release images are assembled from # release/docker with binaries produced outside Docker by @@ -10,7 +10,6 @@ # # Build from the repository root: # -# docker build -f docker/Dockerfile --target mint -t registry-mint . # docker build -f docker/Dockerfile --target evidence -t registry-evidence . # docker build -f docker/Dockerfile --target casework -t registry-casework . # @@ -32,40 +31,9 @@ FROM chef AS planner COPY Cargo.toml Cargo.lock ./ COPY crates ./crates COPY products ./products -RUN cargo chef prepare --recipe-path recipe-mint.json --bin mint \ - && cargo chef prepare --recipe-path recipe-evidence.json --bin evidence \ +RUN cargo chef prepare --recipe-path recipe-evidence.json --bin evidence \ && cargo chef prepare --recipe-path recipe-casework.json --bin casework -FROM chef AS mint-builder -COPY --from=planner /workspace/recipe-mint.json recipe.json -# No --locked here: the bin-filtered recipe carries a lockfile trimmed to the -# closure, which cargo would refuse to reconcile. The final build is --locked. -RUN cargo chef cook --release --recipe-path recipe.json --bin mint -COPY Cargo.toml Cargo.lock ./ -COPY crates ./crates -COPY products ./products -RUN cargo build --release --locked -p registry-mint --bin mint \ - && mkdir -p /workspace/runtime-root/etc/registry-mint \ - && chown -R 65532:65532 /workspace/runtime-root - -# Distroless cc keeps glibc and CA certificates while dropping shell/package tools. -FROM gcr.io/distroless/cc-debian13:nonroot@sha256:c31ff9abcb1910f3ab25c7957bdaf0bfe12a01eb546e8df2282f1c8f682b606c AS mint - -COPY --from=mint-builder /workspace/runtime-root/ / -COPY --from=mint-builder /workspace/target/release/mint /usr/local/bin/mint -COPY LICENSE /licenses/registry-mint/LICENSE - -# The configuration is a startup-only deployment artifact; mount it read-only -# at /etc/registry-mint. Key material and the client registry live beside it -# at whatever paths the configuration names. -ENV MINT_CONFIG=/etc/registry-mint/config.yaml -EXPOSE 8081 - -# No HEALTHCHECK: distroless has no shell or curl and the binary has no -# healthcheck subcommand. The service serves GET /health for HTTP probes. -ENTRYPOINT ["/usr/local/bin/mint"] -CMD ["serve"] - FROM chef AS evidence-builder COPY --from=planner /workspace/recipe-evidence.json recipe.json # No --locked here: the bin-filtered recipe carries a lockfile trimmed to the diff --git a/docker/README.md b/docker/README.md index 9d9461d701..484bde9fbe 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,18 +1,16 @@ # Development images -Distroless container images for the `mint`, `evidence`, and `casework` services, built +Distroless container images for the `evidence` and `casework` services, built entirely inside Docker from the repository root: ```sh -docker build -f docker/Dockerfile --target mint -t registry-mint . docker build -f docker/Dockerfile --target evidence -t registry-evidence . docker build -f docker/Dockerfile --target casework -t registry-casework . ``` These locally built images are **not release evidence**. Starting with -`v0.21.0`, the official Evidence, Mint, and Relay images are +`v0.21.0`, the official Evidence and Relay images are `ghcr.io/registrystack/evidence:v0.21.0`, -`ghcr.io/registrystack/mint:v0.21.0`, and `ghcr.io/registrystack/relay:v0.21.0`. They are assembled from `release/docker/` with byte-reproducible binaries built outside Docker by `release/scripts/build-release-binaries.sh`. Published deployments should pin @@ -67,29 +65,6 @@ The official images have the same nonroot runtime identity and publish it as the machine-readable `org.registrystack.runtime.uid` and `org.registrystack.runtime.gid` OCI labels. -## Running Mint locally - -The configuration is a startup-only artifact. Mount it read-only at -`/etc/registry-mint` with the signing key and client registry beside it, at -the paths the configuration names (relative paths resolve against the -configuration file's directory). The listener address in the configuration -must be an IP the container can bind. Use a private network address for a -Compose deployment. Point `audit.path` under `/var/lib/registry-mint/audit` -and mount that directory on persistent storage owned by UID and GID 65532: - -```sh -docker run --rm \ - -v "$PWD/deploy/mint:/etc/registry-mint:ro" \ - -v mint-audit:/var/lib/registry-mint \ - registry-mint -``` - -`mint check` validates a deployment without opening a socket: - -```sh -docker run --rm -v "$PWD/deploy/mint:/etc/registry-mint:ro" registry-mint check -``` - ## Running Evidence locally The runtime file, governed bundle, and secret root are startup-only @@ -145,14 +120,12 @@ both fail closed. The option proves containment only; the writability and chain proofs still have to pass. Relay provides the equivalent `relay check --runtime -/etc/relay/runtime.yaml`; Mint provides `mint check ---require-runtime-dependencies`. Both accept the same -`--require-audit-under`. For a Compose deployment containing any -combination of the three official products, use +/etc/relay/runtime.yaml`, including the same `--require-audit-under` option. +For a Compose deployment containing Evidence, Relay, or both, use `docker/runtime-preflight.py` to verify the common container posture first and then run each product's native check in its actual mounts and network. The preflight rejects host or shared network namespaces, entrypoint or command -overrides, alternate Evidence or Mint configuration paths, privileged mode, +overrides, alternate Evidence configuration paths, privileged mode, replacement builds, added capabilities or supplementary groups, host devices, any security option other than one `no-new-privileges` entry, multiple replicas, lifecycle hooks, dynamic-loader overrides, inherited mounts, @@ -187,42 +160,17 @@ short deployment timeout, and `--native-check-timeout-seconds SECONDS` selects any deadline from 30 to 21600 seconds. An expired deadline names the service that exceeded it and fails the preflight. -Selected Compose dependencies are honored. A cold Evidence check can check, -start with `--no-deps`, and readiness-probe a declared Mint dependency before -checking Evidence. The dependency lane is an explicit allowlist: only a -selected service whose product is Mint may be started, because Relay's existing -healthcheck is liveness-only and is not accepted as readiness. A `depends_on` -edge to an official Registry Stack service the operator did not select is -refused and names both ends, because the dependent would otherwise be checked -against a service this run never checked or started; an edge to any other -service starts nothing. The -plan orders every dependency before its dependent, rejects a cycle in the -selected services before running anything, and is otherwise the given selection -order. `docker/compose/docker-compose.mint.yaml` is the cold Mint and Evidence -fixture for that lane; it publishes no host port. - -Services started for dependency checking remain under the operator's Compose -lifecycle. The preflight names them, and the command that stops them, on -success and on any failure, so a partially completed run is recoverable with -the same Compose files. A service whose start did not return successfully is -named as one the preflight could not confirm, because Compose may have created -its container before failing. That command repeats the `--env-file` and `--compose-file` -arguments the preflight was given, because the preflight itself renders the -deployment once and runs every later command against that frozen configuration -on stdin. `--dependency-timeout-seconds` bounds both Mint startup and -readiness polling under one shared deadline. The cold Mint overlay requires -`MINT_HEALTHCHECK_URL` so the probe names the numeric private `/ready` listener -Mint binds rather than the command's loopback default. Native checks consume the -exact rendered Compose JSON already -validated by the static pass, rather than re-reading mutable Compose or -environment files. +The preflight never starts Compose services or their declared dependencies. +Each native check uses `docker compose run --rm --no-deps` and consumes the +exact rendered Compose JSON already validated by the static pass, rather than +re-reading mutable Compose or environment files. Dependencies required by a +native check must already be available in the deployment network. ## Health probes -Neither image declares a Docker `HEALTHCHECK`. Mint provides a strict -`mint healthcheck` command for its private `/ready` endpoint; Evidence serves -`GET /health` and expects an operator-owned HTTP probe. The image itself does -not guess which listener address is reachable from the container namespace. +Evidence serves `GET /health` and expects an operator-owned HTTP probe. The +image does not guess which listener address is reachable from the container +namespace. A Compose healthcheck is a command Docker runs inside the container as the service identity, so the preflight validates it: absent, explicitly disabled, diff --git a/docker/compose/README.md b/docker/compose/README.md index 2cff10d526..90b4b2bcb9 100644 --- a/docker/compose/README.md +++ b/docker/compose/README.md @@ -80,30 +80,14 @@ python3 docker/runtime-preflight.py \ --service evidence=evidence ``` -Every native check runs under a bounded deadline of 1800 seconds. Add +The native check runs under a bounded deadline of 1800 seconds. Add `--native-check-timeout-seconds SECONDS` to select another deadline from 30 to 21600 seconds when a retained audit chain needs longer, or when the deployment -requires a shorter one. The preflight honors selected -`depends_on` edges. If an Evidence overlay declares a selected Mint service as -a dependency, it checks Mint, starts only that service with `--no-deps`, -requires Mint's exact `/ready` response, and then checks Evidence. Relay cannot -be started as a preflight dependency because its existing healthcheck is -liveness-only. A `depends_on` edge to a Registry Stack service you did not -select is refused and names both ends, since the dependent would otherwise be -checked against a service this run never checked or started; an edge to any -other service starts nothing. Add `--dependency-timeout-seconds SECONDS` to change the bounded -shared Mint startup and readiness deadline. The overlay requires -`MINT_HEALTHCHECK_URL` and refuses to render without it, because -`mint healthcheck` otherwise falls back to its loopback default and would report -readiness from a listener the Mint configuration may not bind. Name the numeric -private `/ready` listener Mint binds, loopback included. A started Mint remains -under the operator's Compose lifecycle; the preflight names every service it -started, and the `docker compose stop` command that stops those containers, -whether the run passed or failed. A Mint whose start did not return -successfully is named as one the preflight could not confirm, since Compose may -have created its container before failing. That command repeats the -`--env-file` and `--compose-file` arguments you passed, so it targets the same -project the preflight started them in. +requires a shorter one. The preflight starts no Compose services or declared +dependencies. It runs the selected native check with `docker compose run --rm +--no-deps`, so any runtime dependency must already be available in the +deployment network. + The preflight accepts only Docker-managed local named audit volumes without driver options, or explicit bind mounts outside known ephemeral host paths. It rejects service-level tmpfs and every long-form tmpfs other than exactly one @@ -125,52 +109,5 @@ The bundle revision remains unchanged when only the container runtime changes. R only when the governed bundle changes. The runtime and bundle being read-only does not waive secret owner or mode checks for the container service identity. -## Optional Mint - -Mint is a separate service and is intentionally absent from the base adapter, so an Evidence-only -deployment has no Mint configuration dependency. When the deployment has no suitable OIDC issuer, -add an operator-owned Mint service with its configuration, public signing keys, client registry, -reviewed image, private listener, and dedicated Transit socket mounted independently. Mint receives -no provider token or private signing key. Mint's configured issuer and JWKS URI remain public HTTPS -identities. Operator routing or split DNS resolves that public identity within the Compose network. - -`docker-compose.mint.yaml` is that overlay. It adds the Mint service, the -`mint-audit` volume, and the `evidence` to `mint` `depends_on` edge the preflight -reads. Apply it after the base file, in overlay order, and set its inputs -alongside the base adapter's: - -- `MINT_CONFIG_DIR`, the Mint configuration, public keys, and client registry. -- `MINT_SECRET_ROOT`, owner-only Mint secret files. -- `MINT_TRANSIT_SOCKET_DIR`, the dedicated directory containing `transit-proxy.sock`. -- `MINT_HEALTHCHECK_URL`, the private `/ready` URL the Mint configuration binds. -- `MINT_IMAGE`, a reviewed, digest-pinned Mint image. - -The Mint configuration is operator-owned; see -[`crates/registry-mint/README.md`](../../crates/registry-mint/README.md). Inside -the container it must keep `MINT_CONFIG` at `/etc/registry-mint/config.yaml`, -resolve `secretProviders.file.root` to `/run/secrets/registry-mint`, resolve -`signer.unixSocketPath` under `/run/registry-mint`, and place `audit.path` under -`/var/lib/registry-mint`, which the `mint-audit` volume backs. Provision that -volume so UID and GID `65532` can create and append the audit chain before the -first start. - -```sh -python3 docker/runtime-preflight.py \ - --compose-file docker/compose/docker-compose.yaml \ - --compose-file docker/compose/docker-compose.mint.yaml \ - --service mint=mint \ - --service evidence=evidence -``` - -From cold, that checks Mint in its real mounts, starts only Mint with -`--no-deps`, probes `mint healthcheck` until Mint's exact `/ready` response -arrives or the shared dependency deadline expires, and only then checks -Evidence. Neither service publishes a host port. Evidence still reaches Mint by -Mint's public issuer identity, so the operator's routing and TLS for that -identity must already resolve inside this network before Evidence's issuer -reachability check can pass. The overlay declares `condition: service_started` -rather than `service_healthy` because neither official image declares a Docker -`HEALTHCHECK`; readiness is the preflight's probe, not a Compose condition. - This adapter does not establish image provenance, TLS, routing, client registration, or secret ownership. Those remain operator responsibilities. diff --git a/docker/compose/docker-compose.mint.yaml b/docker/compose/docker-compose.mint.yaml deleted file mode 100644 index 0e6c397a20..0000000000 --- a/docker/compose/docker-compose.mint.yaml +++ /dev/null @@ -1,54 +0,0 @@ -# Optional cold-start overlay adding an operator-owned Mint service beside the -# Evidence adapter. Apply it after docker-compose.yaml, in overlay order: -# -# docker compose -f docker-compose.yaml -f docker-compose.mint.yaml config -# -# Required environment, in addition to the base adapter's: -# MINT_CONFIG_DIR Mint configuration, public keys, and client registry -# MINT_SECRET_ROOT owner-only Mint secret directory -# MINT_TRANSIT_SOCKET_DIR directory containing transit-proxy.sock -# MINT_HEALTHCHECK_URL Mint's configured private /ready URL -# MINT_IMAGE reviewed Mint image by digest -# -# Mint publishes no host port. Its issuer and JWKS identities stay public -# HTTPS identities that operator routing resolves inside this network. - -services: - mint: - image: ${MINT_IMAGE:?set MINT_IMAGE to a reviewed image digest} - user: "65532:65532" - read_only: true - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - restart: unless-stopped - environment: - # The preflight readiness probe runs `mint healthcheck` inside this - # container. Name the listener the Mint configuration binds. - MINT_HEALTHCHECK_URL: ${MINT_HEALTHCHECK_URL:?set MINT_HEALTHCHECK_URL to Mint's private /ready URL} - volumes: - - ${MINT_CONFIG_DIR:?set MINT_CONFIG_DIR to the Mint configuration directory}:/etc/registry-mint:ro - - ${MINT_SECRET_ROOT:?set MINT_SECRET_ROOT to the Mint secret root}:/run/secrets/registry-mint:ro - # Mint receives only the workload-local proxy socket, never its Transit - # token or private signing key. - - ${MINT_TRANSIT_SOCKET_DIR:?set MINT_TRANSIT_SOCKET_DIR to the Transit socket directory}:/run/registry-mint:ro - # The append-only audit chain must outlive the container. - - mint-audit:/var/lib/registry-mint - # Docker otherwise provides an implicit writable /dev/shm tmpfs. Keep it - # read-only so the audit root is the only regular-file write lane. - - type: tmpfs - target: /dev/shm - read_only: true - networks: [evidence-net] - - evidence: - # The preflight reads this edge: it checks Mint, starts only Mint with - # --no-deps, and probes Mint's readiness before checking Evidence. The - # condition stays service_started because neither official image declares - # a Docker HEALTHCHECK; readiness is the preflight's `mint healthcheck` - # probe, not a Compose condition. - depends_on: - mint: - condition: service_started - -volumes: - mint-audit: diff --git a/docker/runtime-preflight.py b/docker/runtime-preflight.py index c010ad6060..3e0a070af4 100755 --- a/docker/runtime-preflight.py +++ b/docker/runtime-preflight.py @@ -5,29 +5,24 @@ import argparse import json -import math import os import re -import shlex import subprocess import sys import threading -import time from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Any, Sequence, TextIO +from typing import Any, Sequence MAXIMUM_COMPOSE_BYTES = 4 * 1024 * 1024 MAXIMUM_NATIVE_CHECK_STDERR_BYTES = 4 * 1024 CAPTURE_CHUNK_BYTES = 64 * 1024 CAPTURE_DRAIN_SECONDS = 5 -MINIMUM_DEPENDENCY_TIMEOUT_SECONDS = 5 -MAXIMUM_DEPENDENCY_TIMEOUT_SECONDS = 10 * 60 MINIMUM_NATIVE_CHECK_TIMEOUT_SECONDS = 30 MAXIMUM_NATIVE_CHECK_TIMEOUT_SECONDS = 6 * 60 * 60 DEFAULT_NATIVE_CHECK_TIMEOUT_SECONDS = 30 * 60 -PRODUCTS = ("evidence", "mint", "relay") +PRODUCTS = ("evidence", "relay") SERVICE_PATTERN = re.compile(r"[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?") IMAGE_PATTERNS = { product: re.compile(rf"ghcr\.io/registrystack/{product}@sha256:[0-9a-f]{{64}}") @@ -35,13 +30,9 @@ } AUDIT_PREFIXES = { "evidence": "/var/lib/registry-evidence", - "mint": "/var/lib/registry-mint", "relay": "/var/lib/relay/audit", } EXECUTABLE_PATHS = {product: f"/usr/local/bin/{product}" for product in PRODUCTS} -OFFICIAL_IMAGE_REPOSITORIES = tuple( - f"ghcr.io/registrystack/{product}" for product in PRODUCTS -) AUDIT_CONTAINMENT_FLAG = "--require-audit-under" # An image whose check command predates the containment flag rejects it as an # unknown argument. These are the argument parsers' phrasings for that refusal. @@ -100,14 +91,6 @@ AUDIT_CONTAINMENT_FLAG, AUDIT_PREFIXES["evidence"], ], - "mint": [ - "check", - "--config", - "/etc/registry-mint/config.yaml", - "--require-runtime-dependencies", - AUDIT_CONTAINMENT_FLAG, - AUDIT_PREFIXES["mint"], - ], "relay": [ "check", "--runtime", @@ -116,7 +99,6 @@ AUDIT_PREFIXES["relay"], ], } -DEPENDENCY_HEALTHCHECKS = {"mint": ["/usr/local/bin/mint", "healthcheck"]} class PreflightError(RuntimeError): @@ -137,7 +119,7 @@ def parse_service(raw: str) -> ServiceSelection: or SERVICE_PATTERN.fullmatch(service) is None ): raise PreflightError( - "service selection must be PRODUCT=SERVICE for evidence, mint, or relay" + "service selection must be PRODUCT=SERVICE for evidence or relay" ) return ServiceSelection(product, service) @@ -452,20 +434,6 @@ def validate_healthcheck(service: dict[str, Any], executable: PurePosixPath) -> ) -def names_official_image(image: Any) -> bool: - """Whether the reference names an official product image, by tag or digest. - - Recognizing one is not accepting it: a service the preflight cannot check - is named so the operator can select it or remove the edge. - """ - if not isinstance(image, str): - return False - return any( - image == repository or image.startswith((f"{repository}:", f"{repository}@")) - for repository in OFFICIAL_IMAGE_REPOSITORIES - ) - - def validate_ports(service: dict[str, Any]) -> None: network_mode = service.get("network_mode") if network_mode is not None and not isinstance(network_mode, str): @@ -548,7 +516,6 @@ def validate_service(selection: ServiceSelection, document: dict[str, Any]) -> N "REGISTRY_EVIDENCE_RUNTIME", "/etc/registry-evidence/runtime.yaml", ), - "mint": ("MINT_CONFIG", "/etc/registry-mint/config.yaml"), }.get(selection.product) if fixed_config is not None: name, expected = fixed_config @@ -691,134 +658,6 @@ def native_check( ) -def native_check_plan( - selections: list[ServiceSelection], document: dict[str, Any] -) -> tuple[list[ServiceSelection], set[str]]: - services = document.get("services") - if not isinstance(services, dict): - raise PreflightError("rendered Compose configuration has no services") - selected_by_service = {selection.service: selection for selection in selections} - selected_services = set(selected_by_service) - dependencies: dict[str, set[str]] = {} - dependency_services: set[str] = set() - for selection in selections: - service = services.get(selection.service) - if not isinstance(service, dict): - raise PreflightError( - "selected service is absent from the Compose deployment" - ) - raw = service.get("depends_on", {}) - if isinstance(raw, dict): - names = raw.keys() - elif isinstance(raw, list) and all(isinstance(item, str) for item in raw): - names = raw - else: - raise PreflightError("service dependency posture is invalid") - selected_dependencies = set(names) & selected_services - for dependency in sorted(set(names) - selected_services): - declared = services.get(dependency) - if isinstance(declared, dict) and names_official_image( - declared.get("image") - ): - # Ignoring the edge would check the dependent against a - # Registry Stack service this run never checked or started. - raise PreflightError( - f"selected service {selection.service} depends on Registry " - f"Stack service {dependency}, which was not selected. Select " - "it so the preflight checks and starts it, or remove the edge" - ) - for dependency in selected_dependencies: - if selected_by_service[dependency].product not in DEPENDENCY_HEALTHCHECKS: - raise PreflightError( - "only Mint can be started as a preflight dependency" - ) - dependencies[selection.service] = selected_dependencies - dependency_services.update(selected_dependencies) - - ordered: list[ServiceSelection] = [] - remaining = list(selections) - completed: set[str] = set() - while remaining: - ready = next( - ( - selection - for selection in remaining - if dependencies[selection.service] <= completed - ), - None, - ) - if ready is None: - raise PreflightError("selected services contain a dependency cycle") - remaining.remove(ready) - ordered.append(ready) - completed.add(ready.service) - return ordered, dependency_services - - -def start_dependency( - selection: ServiceSelection, deadline: float, frozen_compose: str -) -> None: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise PreflightError( - f"dependency service {selection.service} did not become ready" - ) - not_started = f"dependency service {selection.service} could not be started" - result = run_compose( - [ - "docker", - "compose", - "--file", - "-", - "up", - "--detach", - "--no-deps", - selection.service, - ], - timeout=max(1, math.ceil(remaining)), - capture_output=False, - input_text=frozen_compose, - timeout_message=not_started, - ) - if result.returncode != 0: - raise PreflightError(not_started) - - -def wait_for_dependency( - selection: ServiceSelection, deadline: float, frozen_compose: str -) -> None: - healthcheck = DEPENDENCY_HEALTHCHECKS.get(selection.product) - if healthcheck is None: - raise PreflightError("only Mint can be started as a preflight dependency") - not_ready = f"dependency service {selection.service} did not become ready" - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise PreflightError(not_ready) - result = run_compose( - [ - "docker", - "compose", - "--file", - "-", - "exec", - "--no-TTY", - selection.service, - *healthcheck, - ], - timeout=max(1, min(6, int(remaining))), - capture_output=False, - input_text=frozen_compose, - timeout_is_failure=False, - ) - remaining = deadline - time.monotonic() - if result.returncode == 0 and remaining > 0: - return - if remaining <= 0: - raise PreflightError(not_ready) - time.sleep(min(1.0, remaining)) - - def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Preflight official Registry Stack services through Docker Compose." @@ -841,7 +680,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: "--service", action="append", required=True, - help="PRODUCT=SERVICE; repeat for each Evidence, Mint, or Relay service", + help="PRODUCT=SERVICE; repeat for each Evidence or Relay service", ) parser.add_argument( "--native-check-timeout-seconds", @@ -856,16 +695,6 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: f"{DEFAULT_NATIVE_CHECK_TIMEOUT_SECONDS} seconds" ), ) - parser.add_argument( - "--dependency-timeout-seconds", - type=lambda raw: bounded_seconds( - raw, - minimum=MINIMUM_DEPENDENCY_TIMEOUT_SECONDS, - maximum=MAXIMUM_DEPENDENCY_TIMEOUT_SECONDS, - ), - default=90, - help="bounded deadline for each declared Mint dependency to become ready", - ) return parser.parse_args(argv) @@ -886,44 +715,9 @@ def bounded_seconds(raw: str, *, minimum: int, maximum: int) -> int: return value -def report_started_dependencies( - running: Sequence[str], - uncertain: Sequence[str], - prefix: Sequence[str], - stream: TextIO, -) -> None: - """Name the dependency services the operator now owns and how to stop them. - - The preflight renders the deployment once and runs every later command - against that frozen configuration on stdin, so the recovery command has to - repeat the operator's own Compose invocation instead. Anything else targets - a different project and leaves the started services running. A start that - did not return successfully is reported separately, because Compose may have - created the container before failing and may not have. - """ - if not running and not uncertain: - return - sentences = [] - if running: - sentences.append( - "dependency services started by the preflight remain running under " - f"the operator's Compose lifecycle: {' '.join(running)}." - ) - if uncertain: - sentences.append( - "the preflight could not confirm the start of, and may have left a " - f"container for: {' '.join(uncertain)}." - ) - recovery = shlex.join([*prefix, "stop", *running, *uncertain]) - sentences.append(f"Stop them with the same Compose files: {recovery}") - print(" ".join(sentences), file=stream) - - def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) prefix = compose_prefix(args) - running: list[str] = [] - uncertain: list[str] = [] try: selections = [parse_service(raw) for raw in args.service] if len(selections) != len({item.service for item in selections}): @@ -932,45 +726,21 @@ def main(argv: Sequence[str] | None = None) -> int: frozen_compose = json.dumps(document, separators=(",", ":")) for selection in selections: validate_service(selection, document) - ordered, dependency_services = native_check_plan(selections, document) - for selection in ordered: + for selection in selections: native_check( selection, args.native_check_timeout_seconds, frozen_compose, ) - if selection.service in dependency_services: - deadline = time.monotonic() + args.dependency_timeout_seconds - uncertain.append(selection.service) - start_dependency( - selection, - deadline, - frozen_compose, - ) - uncertain.remove(selection.service) - running.append(selection.service) - wait_for_dependency( - selection, - deadline, - frozen_compose, - ) except PreflightError as error: print(f"runtime preflight failed: {error}", file=sys.stderr) - report_started_dependencies(running, uncertain, prefix, sys.stderr) return 1 - except BaseException: - # A failure the preflight does not model, an interrupt included, leaves - # the same services behind. The operator gets the list, and the failure - # is raised on rather than swallowed or renamed. - report_started_dependencies(running, uncertain, prefix, sys.stderr) - raise print(f"runtime preflight passed for {len(selections)} service(s)") print( "each configured audit sink resolves inside the declared persistent " "mount; that the storage behind that mount survives is not proven" ) - report_started_dependencies(running, uncertain, prefix, sys.stdout) return 0 diff --git a/docker/test_runtime_preflight.py b/docker/test_runtime_preflight.py index de80fcd355..d48dd89385 100644 --- a/docker/test_runtime_preflight.py +++ b/docker/test_runtime_preflight.py @@ -7,7 +7,6 @@ import importlib.util import io import json -import os import subprocess import sys import unittest @@ -17,57 +16,6 @@ SCRIPT = Path(__file__).with_name("runtime-preflight.py") DIGEST = "a" * 64 -COMPOSE_DIRECTORY = SCRIPT.parent / "compose" -COLD_FIXTURE_FILES = ( - COMPOSE_DIRECTORY / "docker-compose.yaml", - COMPOSE_DIRECTORY / "docker-compose.mint.yaml", -) -# Compose interpolates these without touching the host paths they name, so the -# rendered fixture stays identical wherever the suite runs. -COLD_FIXTURE_ENVIRONMENT = { - "EVIDENCE_CANDIDATE_DIR": "/srv/registry-stack/evidence/candidate", - "EVIDENCE_RUNTIME_FILE": "/srv/registry-stack/evidence/runtime.docker.yaml", - "EVIDENCE_SECRET_ROOT": "/srv/registry-stack/evidence/secrets", - "EVIDENCE_TRANSIT_SOCKET_DIR": "/srv/registry-stack/evidence/transit", - "EVIDENCE_IMAGE": f"ghcr.io/registrystack/evidence@sha256:{DIGEST}", - "MINT_CONFIG_DIR": "/srv/registry-stack/mint/config", - "MINT_SECRET_ROOT": "/srv/registry-stack/mint/secrets", - "MINT_TRANSIT_SOCKET_DIR": "/srv/registry-stack/mint/transit", - "MINT_HEALTHCHECK_URL": "http://127.0.0.1:8081/ready", - "MINT_IMAGE": f"ghcr.io/registrystack/mint@sha256:{DIGEST}", -} - - -def compose_is_available() -> bool: - try: - result = subprocess.run( - ["docker", "compose", "version"], - check=False, - capture_output=True, - text=True, - timeout=60, - ) - except (OSError, subprocess.SubprocessError): - return False - return result.returncode == 0 - - -def render_cold_fixture() -> dict[str, object]: - command = ["docker", "compose"] - for compose_file in COLD_FIXTURE_FILES: - command.extend(["--file", str(compose_file)]) - command.extend(["config", "--format", "json"]) - result = subprocess.run( - command, - check=True, - capture_output=True, - text=True, - env={**os.environ, **COLD_FIXTURE_ENVIRONMENT}, - timeout=120, - ) - return json.loads(result.stdout) - - def load_module(): spec = importlib.util.spec_from_file_location("runtime_preflight", SCRIPT) if spec is None or spec.loader is None: @@ -81,7 +29,6 @@ def load_module(): def service(product: str) -> dict[str, object]: audit = { "evidence": "/var/lib/registry-evidence", - "mint": "/var/lib/registry-mint", "relay": "/var/lib/relay/audit", }[product] return { @@ -123,12 +70,6 @@ def deployment(services: dict[str, dict[str, object]]) -> dict[str, object]: } -def completed(returncode: int = 0) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess( - args=[], returncode=returncode, stdout="sensitive", stderr="sensitive" - ) - - def emitting(effects: list[object]): """Fake `subprocess.run` that writes each effect's stderr to its capture file. @@ -149,14 +90,6 @@ def run(*args: object, **kwargs: object) -> object: return run -def cold_deployment() -> dict[str, object]: - document = deployment({"evidence": service("evidence"), "mint": service("mint")}) - document["services"]["evidence"]["depends_on"] = { # type: ignore[index] - "mint": {"condition": "service_started"} - } - return document - - class RuntimePreflightTest(unittest.TestCase): def setUp(self) -> None: self.module = load_module() @@ -168,8 +101,6 @@ def setUp(self) -> None: "--service", "evidence=evidence", "--service", - "mint=mint", - "--service", "relay=relay", ] @@ -191,7 +122,7 @@ def run_main( stderr=native_stderr, ) run = unittest.mock.Mock( - side_effect=emitting([render, native, native, native]) + side_effect=emitting([render, native, native]) ) stdout = io.StringIO() stderr = io.StringIO() @@ -203,42 +134,21 @@ def run_main( result = self.module.main(self.argv if argv is None else argv) return result, stdout.getvalue(), stderr.getvalue(), run - def run_orchestration( - self, - document: dict[str, object], - effects: list[object], - argv: list[str], - ) -> tuple[int, str, str, unittest.mock.Mock]: - render = subprocess.CompletedProcess( - args=[], returncode=0, stdout=json.dumps(document), stderr="" - ) - run = unittest.mock.Mock(side_effect=emitting([render, *effects])) - stdout = io.StringIO() - stderr = io.StringIO() - with ( - unittest.mock.patch.object(self.module.subprocess, "run", run), - contextlib.redirect_stdout(stdout), - contextlib.redirect_stderr(stderr), - ): - result = self.module.main(argv) - return result, stdout.getvalue(), stderr.getvalue(), run - def test_all_products_use_native_checks_after_complete_static_preflight( self, ) -> None: document = deployment( { "evidence": service("evidence"), - "mint": service("mint"), "relay": service("relay"), } ) result, stdout, stderr, run = self.run_main(document) self.assertEqual(0, result, stderr) self.assertEqual( - "runtime preflight passed for 3 service(s)", stdout.splitlines()[0] + "runtime preflight passed for 2 service(s)", stdout.splitlines()[0] ) - self.assertEqual(4, run.call_count) + self.assertEqual(3, run.call_count) calls = [call.args[0] for call in run.call_args_list] self.assertEqual( [ @@ -255,11 +165,9 @@ def test_all_products_use_native_checks_after_complete_static_preflight( calls[0], ) self.assertIn("--require-runtime-dependencies", calls[1]) - self.assertIn("--require-runtime-dependencies", calls[2]) - self.assertEqual("check", calls[3][-5]) + self.assertEqual("check", calls[2][-5]) self.assertEqual("evidence", calls[1][calls[1].index("--no-deps") + 1]) - self.assertEqual("mint", calls[2][calls[2].index("--no-deps") + 1]) - self.assertEqual("relay", calls[3][calls[3].index("--no-deps") + 1]) + self.assertEqual("relay", calls[2][calls[2].index("--no-deps") + 1]) for call in run.call_args_list[1:]: self.assertIn("--no-deps", call.args[0]) self.assertEqual(["docker", "compose", "--file", "-"], call.args[0][:4]) @@ -280,14 +188,13 @@ def test_every_native_check_asserts_the_validated_audit_prefix(self) -> None: document = deployment( { "evidence": service("evidence"), - "mint": service("mint"), "relay": service("relay"), } ) result, _, stderr, run = self.run_main(document) self.assertEqual(0, result, stderr) calls = [call.args[0] for call in run.call_args_list] - for index, product in enumerate(("evidence", "mint", "relay"), start=1): + for index, product in enumerate(("evidence", "relay"), start=1): with self.subTest(product=product): prefix = self.module.AUDIT_PREFIXES[product] self.assertEqual( @@ -474,52 +381,34 @@ def test_an_audit_root_that_proves_nothing_is_refused(self) -> None: # path resolves under asserts nothing. Such a root is refused before # any native check receives it, rather than passed on as a proof that # cannot fail. - document = deployment({"mint": service("mint")}) - for root in ("/", "", "var/lib/registry-mint", "/var/lib/registry-mint/.."): + document = deployment({"relay": service("relay")}) + for root in ("/", "", "var/lib/relay", "/var/lib/relay/../audit"): with self.subTest(root=root): with unittest.mock.patch.dict( - self.module.AUDIT_PREFIXES, {"mint": root} + self.module.AUDIT_PREFIXES, {"relay": root} ): with self.assertRaises(self.module.PreflightError) as raised: self.module.validate_service( - self.module.ServiceSelection("mint", "mint"), document + self.module.ServiceSelection("relay", "relay"), document ) self.assertIn("audit root", str(raised.exception)) def test_a_passing_run_states_that_persistence_is_not_proven(self) -> None: - document = deployment({"mint": service("mint")}) + document = deployment({"relay": service("relay")}) result, stdout, stderr, _ = self.run_main( document, - argv=["--compose-file", "compose.yaml", "--service", "mint=mint"], + argv=["--compose-file", "compose.yaml", "--service", "relay=relay"], ) self.assertEqual(0, result, stderr) self.assertIn("not proven", stdout) - def test_an_unselected_registry_stack_dependency_is_refused(self) -> None: - # Silently ignoring the edge left Evidence checked against a Mint the - # preflight never checked or started. The refusal names both ends so - # the operator can select the service or remove the edge. - document = cold_deployment() - result, stdout, stderr, run = self.run_orchestration( - document, - [completed()], - ["--compose-file", "compose.yaml", "--service", "evidence=evidence"], - ) - self.assertEqual(1, result) - self.assertEqual("", stdout) - self.assertIn("evidence", stderr) - self.assertIn("mint", stderr) - self.assertIn("was not selected", stderr) - run.assert_called_once() - - def test_a_dependency_outside_the_product_set_starts_nothing(self) -> None: + def test_compose_dependencies_are_not_started_by_the_preflight(self) -> None: document = deployment({"evidence": service("evidence")}) document["services"]["proxy"] = {"image": "example.invalid/proxy:latest"} document["services"]["evidence"]["depends_on"] = ["proxy"] # type: ignore[index] - result, stdout, stderr, run = self.run_orchestration( + result, stdout, stderr, run = self.run_main( document, - [completed()], - ["--compose-file", "compose.yaml", "--service", "evidence=evidence"], + argv=["--compose-file", "compose.yaml", "--service", "evidence=evidence"], ) self.assertEqual(0, result, stderr) self.assertEqual(2, run.call_count) @@ -693,7 +582,7 @@ def test_named_audit_volume_cannot_masquerade_as_a_bind(self) -> None: ) def test_mounts_cannot_shadow_official_executables_or_libraries(self) -> None: - for product in ("evidence", "mint", "relay"): + for product in ("evidence", "relay"): executable = f"/usr/local/bin/{product}" for target in ( "/", @@ -790,7 +679,6 @@ def test_official_configuration_paths_may_not_be_overridden(self) -> None: "REGISTRY_EVIDENCE_RUNTIME", "/etc/registry-evidence/runtime.yaml", ), - "mint": ("MINT_CONFIG", "/etc/registry-mint/config.yaml"), } for product, (name, expected) in fixed.items(): with self.subTest(product=product): @@ -813,7 +701,6 @@ def test_native_failure_is_value_free(self) -> None: document = deployment( { "evidence": service("evidence"), - "mint": service("mint"), "relay": service("relay"), } ) @@ -934,7 +821,6 @@ def test_native_check_deadline_is_bounded_and_operator_configurable(self) -> Non document = deployment( { "evidence": service("evidence"), - "mint": service("mint"), "relay": service("relay"), } ) @@ -1008,401 +894,16 @@ def test_an_expired_native_check_deadline_fails_without_output(self) -> None: self.assertNotIn("sensitive", stderr.getvalue()) self.assertIn("native runtime check deadline", stderr.getvalue()) - def test_cold_mint_dependency_is_checked_started_probed_then_consumed(self) -> None: - document = deployment( - { - "evidence": service("evidence"), - "mint": service("mint"), - "unrelated": {"image": "example.invalid/unrelated:latest"}, - } - ) - document["services"]["evidence"]["depends_on"] = { # type: ignore[index] - "mint": {"condition": "service_healthy", "required": True} - } - complete = lambda returncode=0: subprocess.CompletedProcess( # noqa: E731 - args=[], returncode=returncode, stdout="sensitive", stderr="sensitive" - ) - render = complete() - render.stdout = json.dumps(document) - run = unittest.mock.Mock( - side_effect=[render, complete(), complete(), complete(), complete()] - ) - argv = [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - "--native-check-timeout-seconds", - "600", - "--dependency-timeout-seconds", - "240", - ] - stdout = io.StringIO() - stderr = io.StringIO() - with ( - unittest.mock.patch.object(self.module.subprocess, "run", run), - contextlib.redirect_stdout(stdout), - contextlib.redirect_stderr(stderr), - ): - result = self.module.main(argv) - - self.assertEqual(0, result, stderr.getvalue()) - calls = [call.args[0] for call in run.call_args_list] - self.assertEqual("mint", calls[1][calls[1].index("--no-deps") + 1]) - self.assertEqual(["up", "--detach", "--no-deps", "mint"], calls[2][-4:]) - self.assertEqual( - [ - "exec", - "--no-TTY", - "mint", - "/usr/local/bin/mint", - "healthcheck", - ], - calls[3][-5:], - ) - self.assertEqual("evidence", calls[4][calls[4].index("--no-deps") + 1]) - self.assertEqual(600, run.call_args_list[1].kwargs["timeout"]) - self.assertEqual(240, run.call_args_list[2].kwargs["timeout"]) - self.assertEqual(600, run.call_args_list[4].kwargs["timeout"]) - self.assertFalse(any("unrelated" in call for call in calls)) - for call in run.call_args_list[1:]: - self.assertEqual(document, json.loads(call.kwargs["input"])) - self.assertEqual(self.module.subprocess.DEVNULL, call.kwargs["stdout"]) - for index in (1, 4): - captured = run.call_args_list[index].kwargs["stderr"] - self.assertTrue(hasattr(captured, "write")) - for index in (2, 3): - self.assertEqual( - self.module.subprocess.DEVNULL, - run.call_args_list[index].kwargs["stderr"], - ) - - def test_unhealthy_mint_blocks_the_dependent_native_check(self) -> None: - document = deployment( - {"evidence": service("evidence"), "mint": service("mint")} - ) - document["services"]["evidence"]["depends_on"] = ["mint"] # type: ignore[index] - render = subprocess.CompletedProcess( - args=[], returncode=0, stdout=json.dumps(document), stderr="" - ) - complete = subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - failed = subprocess.CompletedProcess( - args=[], returncode=1, stdout="", stderr="" - ) - run = unittest.mock.Mock(side_effect=[render, complete, complete, failed]) - stdout = io.StringIO() - stderr = io.StringIO() - with ( - unittest.mock.patch.object(self.module.subprocess, "run", run), - unittest.mock.patch.object( - self.module.time, - "monotonic", - side_effect=[0.0, 0.0, 0.0, 0.0, 6.0], - ), - contextlib.redirect_stdout(stdout), - contextlib.redirect_stderr(stderr), - ): - result = self.module.main( - [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - "--dependency-timeout-seconds", - "5", - ] - ) - - self.assertEqual(1, result) - self.assertEqual("", stdout.getvalue()) - self.assertIn("did not become ready", stderr.getvalue()) - self.assertIn( - "docker compose --file compose.yaml stop mint", stderr.getvalue() - ) - self.assertEqual(4, run.call_count) - self.assertFalse( - any( - "evidence" in call.args[0] and "run" in call.args[0] - for call in run.call_args_list - ) - ) - - def test_dependency_order_is_deterministic_across_selection_order(self) -> None: - document = deployment( - { - "evidence": service("evidence"), - "mint": service("mint"), - "relay": service("relay"), - } - ) - document["services"]["evidence"]["depends_on"] = ["mint"] # type: ignore[index] - selections = [ - self.module.ServiceSelection("evidence", "evidence"), - self.module.ServiceSelection("mint", "mint"), - self.module.ServiceSelection("relay", "relay"), - ] - for selected in (selections, list(reversed(selections))): - with self.subTest(selected=[item.service for item in selected]): - ordered, dependencies = self.module.native_check_plan( - list(selected), document - ) - names = [item.service for item in ordered] - self.assertCountEqual( - [item.service for item in selected], names, names - ) - self.assertLess(names.index("mint"), names.index("evidence")) - self.assertEqual({"mint"}, dependencies) - repeated, _ = self.module.native_check_plan(list(selected), document) - self.assertEqual(names, [item.service for item in repeated]) - - def test_an_unavailable_mint_fails_before_the_dependent_check(self) -> None: - document = cold_deployment() - result, stdout, stderr, run = self.run_orchestration( - document, - [completed(), completed(returncode=1)], - [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - ], - ) - self.assertEqual(1, result) - self.assertEqual("", stdout) - self.assertNotIn("sensitive", stderr) - self.assertIn("could not be started", stderr) - self.assertEqual(3, run.call_count) - self.assertEqual( - ["up", "--detach", "--no-deps", "mint"], - run.call_args_list[2].args[0][-4:], - ) - - def test_started_dependencies_are_reported_for_operator_recovery(self) -> None: - document = cold_deployment() - argv = [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - ] - result, stdout, stderr, _ = self.run_orchestration( - document, - [completed(), completed(), completed(), completed()], - argv, - ) - self.assertEqual(0, result, stderr) - self.assertIn("docker compose --file compose.yaml stop mint", stdout) - self.assertIn("remain running", stdout) - - result, stdout, stderr, _ = self.run_orchestration( - document, - [completed(), completed(), completed(), completed(returncode=1)], - argv, - ) - self.assertEqual(1, result) - self.assertEqual("", stdout) - self.assertIn("native runtime check", stderr) - self.assertIn("docker compose --file compose.yaml stop mint", stderr) - - result, stdout, stderr, _ = self.run_orchestration( - document, - [completed(), completed(returncode=1)], - argv, - ) - self.assertEqual(1, result) - self.assertIn("docker compose --file compose.yaml stop mint", stderr) - - def test_a_dependency_that_failed_to_start_is_not_reported_as_running( - self, - ) -> None: - # Compose may have created the container before failing, or not. The - # hint has to say which of the two lists a service is in. - document = cold_deployment() - result, stdout, stderr, _ = self.run_orchestration( - document, - [completed(), completed(returncode=1)], - [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - ], - ) - self.assertEqual(1, result) - self.assertEqual("", stdout) - self.assertIn("could not be started", stderr) - self.assertIn("could not confirm", stderr) - self.assertNotIn("remain running", stderr) - self.assertIn("docker compose --file compose.yaml stop mint", stderr) - - def test_started_dependencies_are_named_when_an_unexpected_failure_escapes( - self, - ) -> None: - # A failure the preflight does not model still leaves Mint running, and - # the operator still has to stop it. The failure itself is not swallowed - # and its text is not echoed. - document = cold_deployment() - render = subprocess.CompletedProcess( - args=[], returncode=0, stdout=json.dumps(document), stderr="" - ) - run = unittest.mock.Mock( - side_effect=emitting( - [ - render, - completed(), - completed(), - completed(), - RuntimeError("sensitive daemon detail"), - ] - ) - ) - stdout = io.StringIO() - stderr = io.StringIO() - with ( - unittest.mock.patch.object(self.module.subprocess, "run", run), - contextlib.redirect_stdout(stdout), - contextlib.redirect_stderr(stderr), - ): - with self.assertRaises(RuntimeError): - self.module.main( - [ - "--compose-file", - "compose.yaml", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - ] - ) - - self.assertIn("remain running", stderr.getvalue()) - self.assertIn( - "docker compose --file compose.yaml stop mint", stderr.getvalue() - ) - self.assertNotIn("sensitive", stderr.getvalue()) - - def test_the_recovery_hint_repeats_the_operator_compose_invocation(self) -> None: - # The preflight renders with `--file -`, so the hint has to name the - # operator's own files. Without them the operator stops services in a - # different project and the started dependency keeps running. - document = cold_deployment() - result, stdout, stderr, _ = self.run_orchestration( - document, - [completed(), completed(), completed(), completed()], - [ - "--compose-file", - "compose.yaml", - "--compose-file", - "overlay compose.yaml", - "--env-file", - "operator env", - "--service", - "evidence=evidence", - "--service", - "mint=mint", - ], - ) - self.assertEqual(0, result, stderr) - self.assertIn( - "docker compose --env-file 'operator env' --file compose.yaml " - "--file 'overlay compose.yaml' stop mint", - stdout, - ) - self.assertNotIn("--file -", stdout) - - def test_the_recovery_hint_without_compose_flags_names_the_services(self) -> None: - stream = io.StringIO() - self.module.report_started_dependencies( - ["mint"], [], ["docker", "compose"], stream - ) - self.assertIn("docker compose stop mint", stream.getvalue()) - - def test_the_cold_fixture_passes_without_publishing_a_host_port(self) -> None: - if not compose_is_available(): - self.skipTest("docker compose renders the shipped cold fixture") - document = render_cold_fixture() - for name in ("evidence", "mint"): - self.assertIsNone(document["services"][name].get("ports")) # type: ignore[index] - argv: list[str] = [] - for compose_file in COLD_FIXTURE_FILES: - argv.extend(["--compose-file", str(compose_file)]) - argv.extend(["--service", "evidence=evidence", "--service", "mint=mint"]) - result, stdout, stderr, run = self.run_orchestration( - document, - [completed(), completed(), completed(), completed()], - argv, - ) - self.assertEqual(0, result, stderr) - self.assertNotIn("sensitive", stdout) - calls = [call.args[0] for call in run.call_args_list] - self.assertEqual("mint", calls[1][calls[1].index("--no-deps") + 1]) - self.assertEqual(["up", "--detach", "--no-deps", "mint"], calls[2][-4:]) - self.assertEqual( - ["exec", "--no-TTY", "mint", "/usr/local/bin/mint", "healthcheck"], - calls[3][-5:], - ) - self.assertEqual("evidence", calls[4][calls[4].index("--no-deps") + 1]) - - def test_probe_success_after_the_shared_deadline_is_rejected(self) -> None: - complete = subprocess.CompletedProcess( - args=[], returncode=0, stdout="", stderr="" - ) - selection = self.module.ServiceSelection("mint", "mint") - with ( - unittest.mock.patch.object( - self.module, "run_compose", return_value=complete - ), - unittest.mock.patch.object( - self.module.time, "monotonic", side_effect=[0.0, 6.0] - ), - ): - with self.assertRaisesRegex(self.module.PreflightError, "ready"): - self.module.wait_for_dependency(selection, 5.0, "{}") - - def test_only_mint_may_be_started_as_a_dependency(self) -> None: - selections = [ - self.module.ServiceSelection("evidence", "evidence"), - self.module.ServiceSelection("relay", "relay"), - ] - document = deployment( - {"evidence": service("evidence"), "relay": service("relay")} - ) - document["services"]["evidence"]["depends_on"] = ["relay"] # type: ignore[index] - with self.assertRaisesRegex(self.module.PreflightError, "only Mint"): - self.module.native_check_plan(selections, document) - - def test_selected_dependency_cycles_fail_before_native_checks(self) -> None: - selections = [ - self.module.ServiceSelection("evidence", "evidence"), - self.module.ServiceSelection("mint", "mint"), - ] - document = deployment( - { - "evidence": service("evidence"), - "mint": service("mint"), - } - ) - document["services"]["evidence"]["depends_on"] = ["mint"] # type: ignore[index] - document["services"]["mint"]["depends_on"] = ["evidence"] # type: ignore[index] - with self.assertRaises(self.module.PreflightError): - self.module.native_check_plan(selections, document) - def test_parser_rejects_duplicates_and_unsafe_service_names(self) -> None: with self.assertRaises(self.module.PreflightError): self.module.closed_json('{"services":{},"services":{}}') - for value in ["other=service", "relay=../service", "relay=", "relay=a b"]: + for value in [ + "mint=service", + "other=service", + "relay=../service", + "relay=", + "relay=a b", + ]: with self.assertRaises(self.module.PreflightError): self.module.parse_service(value) with self.assertRaises(self.module.argparse.ArgumentTypeError): diff --git a/docs/site/scripts/evidence-production-build-docs.test.mjs b/docs/site/scripts/evidence-production-build-docs.test.mjs index 75ac4126a9..38f8bc3c51 100644 --- a/docs/site/scripts/evidence-production-build-docs.test.mjs +++ b/docs/site/scripts/evidence-production-build-docs.test.mjs @@ -61,7 +61,6 @@ test('the maintained Compose adapter keeps Evidence deployment inputs explicit', readFile(resolve(repoRoot, 'docker/compose/runtime.docker.yaml'), 'utf8'), ]); - assert.match(readme, /intentionally absent from the base adapter/u); for (const name of [ 'EVIDENCE_CANDIDATE_DIR', 'EVIDENCE_RUNTIME_FILE', diff --git a/products/breg/README.md b/products/breg/README.md index e778279047..99f38180ee 100644 --- a/products/breg/README.md +++ b/products/breg/README.md @@ -43,15 +43,15 @@ For a generic, domain-neutral local path, run: products/breg/quickstart/run.sh ``` -Pass `--installed` to use released `breg`, `bregctl`, and `mint` binaries from -`PATH` instead of building them from this checkout with Cargo. +Pass `--installed` to use released `breg` and `bregctl` binaries from `PATH` +instead of building them from this checkout with Cargo. The quickstart uses `bregctl init` to create a small generic Registry project, replaces its package identity with a local one for the -disposable package, checks it, starts disposable PostgreSQL and Registry Mint on loopback, activates -an unsigned local package, obtains a short-lived Mint token, POSTs one record, -and GETs that record back. Generated configuration, keys, tokens, package -artifacts, logs, and database URLs stay under +disposable package, checks it, starts disposable PostgreSQL and the pinned stock +ThunderID issuer on loopback, activates an unsigned local package, obtains a +short-lived token, POSTs one record, and GETs that record back. Generated +configuration, keys, tokens, package artifacts, logs, and database URLs stay under `products/breg/quickstart/.run/`, which is ignored by Git and created owner-only. @@ -76,8 +76,8 @@ To verify only the checked quickstart structure without Docker or network, run: products/breg/quickstart/self-test.sh ``` -This route is intentionally local-only: Mint's supervised local-development -profile, loopback HTTP, disposable PostgreSQL, and an unsigned local package. +This route is intentionally local-only: the supervised stock ThunderID issuer, +loopback HTTP, disposable PostgreSQL, and an unsigned local package. It is the first-hour learning path, not a shortcut around production package signing, operated database roles, TLS, migration review, or secret custody. @@ -204,8 +204,8 @@ retention boundaries, see [Corrections and historical queries](HISTORY.md). [Point queries and QGIS](SPATIAL-QUERIES.md) describes GeoJSON output and explicitly granted PostGIS-backed bbox queries. The spatial quickstart uses -the same BReg, Mint and package lifecycle; ordinary registries do not need -PostGIS. +the same BReg, stock ThunderID issuer, and package lifecycle; ordinary +registries do not need PostGIS. Base Registry Engine owns typed configured storage, generated REST contracts, authorization, record revisions, audit ordering, idempotency, outbox creation, @@ -260,7 +260,8 @@ products/breg/scripts/check-contracts.sh ``` For an interactive local business example backed by disposable PostgreSQL, -Registry Mint, a real local package, and deterministic relational data, run: +the pinned stock ThunderID issuer, a real local package, and deterministic +relational data, run: ```bash products/breg/demo/run.sh @@ -356,5 +357,5 @@ product to Base Registry Engine internals. Base Registry Engine is a writable source-of-truth product. Registry Relay remains the separately deployed read-only publication product; Evidence remains the minimum-disclosure assertion product; Manifest receives a safe one-way -metadata projection; Mint may issue configured OIDC tokens; and PublicSchema -is an authoring input rather than a runtime dependency. +metadata projection; an operated OIDC issuer supplies configured tokens; and +PublicSchema is an authoring input rather than a runtime dependency. diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py index c12503372c..a51c6dd24f 100644 --- a/release/scripts/test_check_debian13_images.py +++ b/release/scripts/test_check_debian13_images.py @@ -388,14 +388,14 @@ def test_relay_v2_image_healthcheck_endpoint_is_configurable(self) -> None: failures, ) - def test_mint_and_evidence_image_is_a_required_maintained_surface(self) -> None: + def test_adopter_image_is_a_required_maintained_surface(self) -> None: self.assertEqual( (Path("docker/Dockerfile"),), POLICY.ADOPTER_DOCKERFILES, ) self.assertIn(Path("docker/Dockerfile"), POLICY.MAINTAINED_TEXT_PATHS) - def test_mint_and_evidence_image_requires_pinned_upstream_bases(self) -> None: + def test_adopter_image_requires_pinned_upstream_bases(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self.repository_copy(root) @@ -419,15 +419,15 @@ def test_mint_and_evidence_image_requires_pinned_upstream_bases(self) -> None: failures, ) - def test_mint_and_evidence_distroless_stages_forbid_shell_tooling(self) -> None: + def test_adopter_distroless_stages_forbid_shell_tooling(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) self.repository_copy(root) dockerfile = root / "docker/Dockerfile" dockerfile.write_text( dockerfile.read_text(encoding="utf-8").replace( - "COPY --from=mint-builder /workspace/runtime-root/ /\n", - "COPY --from=mint-builder /workspace/runtime-root/ /\n" + "COPY --from=evidence-builder /workspace/runtime-root/ /\n", + "COPY --from=evidence-builder /workspace/runtime-root/ /\n" "RUN /bin/sh -c true\n", 1, ), diff --git a/release/security/mint-advisory-baseline.json b/release/security/mint-advisory-baseline.json deleted file mode 100644 index 0d117e9d1a..0000000000 --- a/release/security/mint-advisory-baseline.json +++ /dev/null @@ -1,303 +0,0 @@ -{ - "version": 4, - "service": "mint", - "runtime": { - "image": "gcr.io/distroless/cc-debian13:nonroot@sha256:c31ff9abcb1910f3ab25c7957bdaf0bfe12a01eb546e8df2282f1c8f682b606c", - "layer_ids": [ - "sha256:50abe06dfc0957e14cb8332ed242e8b884ef2563a9eb202e5172f243f62792f7", - "sha256:621c35e751a51a9a9dc3e80aa0b7fe8be2a93402ea6ccd307d30852cd7776cda", - "sha256:c8b007d0206e4b10ed4d3b3d99dfeab47c2648e82011989fd78a5731baf33fc3", - "sha256:bec7e6bb35e05d1284f28b10d2150c259717d91c658c4c10c08424bb9466caba", - "sha256:275a30dd8ce958b21daa9ad962c6fbc09f98306ee2f486b65c9075dc257b1412", - "sha256:4d049f83d9cf21d1f5cc0e11deaf36df02790d0e60c1a3829538fb4b61685368", - "sha256:af5aa97ebe6ce1604747ec1e21af7136ded391bcabe4acef882e718a87c86bcc", - "sha256:6f1cdceb6a3146f0ccb986521156bef8a422cdbb0863396f7f751f575ba308f4", - "sha256:bd3cdfae1d3fdd83a2231d608969b38b82349777c2fff9a7c12d54f8ac5c9b38", - "sha256:4cde6b0bb6f50a5f255eef7b2a42162c661cf776b803225dcac9a659e396bb6b", - "sha256:ad51d0769d16ba578106a177987dfe3d2e02c1668c852b795b2f6b024068242a", - "sha256:187cfc6d1e3e8a40a5e64653bcd3239c140807dcf1c09e48021178705a5a6139", - "sha256:f2392a9484bf6b18f06beaf705b994e31dbb2716c99cf7d03c9c495cd107f364", - "sha256:3a7299f559d987305122c7669fc3643095eb0955f8ff4a38c9430d54d0b4452e", - "sha256:ada8421c741188c83c2e6de96450043e65ce6233e7d2a1d84cd789a0ad97f420", - "sha256:c0e409312adc366898967307565f692bb33d43a439d3de48e27d14b742389725", - "sha256:e4ba966d7f0527dfe0fcb559e4e18d4da42c4e6beae924719255e0dedb554ed0", - "sha256:1f5d28bd51650f429293f7730ede274b81dc0744aa918bc887133c4ad610258c", - "sha256:6e18ad80f3d64a8cbbcd1ff2e8a0d5ce7282cf664e816b86183a59d30a618e8a", - "sha256:c16b2ec4b1493bad1b1de23d659c899e60abb166bda756d02792f0a03ba54a43", - "sha256:7db505d90756626f425c6c5468eca565c82f589b144ecaa4f411ad9bbf79e614" - ], - "application_layer_ids": [ - "sha256:dd1eab3fedd2610d94647b32b78e2b48388e39e09aba10f1b5e0617c59c17007", - "sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef" - ], - "config": { - "user": "65532", - "entrypoint": [ - "/usr/local/bin/mint" - ], - "command": [ - "serve" - ], - "working_dir": "/var/lib/registry-mint", - "environment": [ - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", - "MINT_CONFIG=/etc/registry-mint/config.yaml" - ], - "healthcheck": null, - "args_escaped": true, - "exposed_ports": [ - "8081/tcp" - ], - "stop_signal": "" - }, - "definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e" - }, - "policies": [ - { - "tool": "zizmor", - "minimum_severity": "high", - "action": "block_unreviewed" - }, - { - "tool": "grype", - "minimum_severity": "high", - "action": "block_unreviewed", - "block_fixable": true - } - ], - "exceptions": [ - { - "vulnerability_id": "CVE-2026-5450", - "package": "libc6", - "installed_version": "2.41-12+deb13u3", - "severity": "Critical", - "status": "accepted_risk", - "owner": "@jeremi", - "rationale": "Debian classifies the Trixie issue as minor/no-DSA and has no Trixie fix. The reviewed v0.30.0 Linux AMD64 Mint local reproduction from rehearsal run 34673966073 has one __isoc99_sscanf call whose two branches select only the immutable %lu and %lx formats. Neither uses an allocating-character conversion or field width, and no other scanf-family call or affected dynamically resolved name was identified. A candidate must retain the complete ordered reference rootfs layers, the production OCI process contract, and the Syft-bound reviewed executable, loader, libc, libm and libpthread file digests.", - "reviewed_at": "2026-09-12", - "expires_at": "2026-09-18", - "invalidation_triggers": [ - "candidate_image_identity_mismatch", - "candidate_rootfs_changed", - "component_layer_changed", - "expired", - "exposure_assertion_changed", - "exposure_assertion_false", - "exposure_assertion_unevaluable", - "fix_available", - "material_finding_changed", - "package_version_changed", - "rootfs_evidence_mismatch", - "runtime_config_changed", - "runtime_base_changed" - ], - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "component_layer_id": "sha256:3a7299f559d987305122c7669fc3643095eb0955f8ff4a38c9430d54d0b4452e", - "exposure_assertion": { - "kind": "whole_image_fingerprint_equals", - "reference_image_digest": "sha256:d485eb3b24eff8d0aaea73afdeb28984e38bddc9a52a0512228a2e77fbfb7bbe", - "reference_source_revision": "6bdeb66a8e37693ef13e0fa629e551a75f109f0c", - "reference_provenance": "local_reproduction", - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "files": [ - { - "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "sha256": "sha256:438c546d8e8cc48496bf3a95f753051afd9db66a629a74e31a9ded71586b56e0" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libc.so.6", - "sha256": "sha256:fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libm.so.6", - "sha256": "sha256:6d567d53e895273ca14a1f9dc164fc6c8d39aed2f60aa46a733c2784228915f3" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libpthread.so.0", - "sha256": "sha256:85e21f7dba0394411d00959176fd18b470e575b0b05f1f4f41e5636802ce0500" - }, - { - "path": "/usr/local/bin/mint", - "sha256": "sha256:31f5cebf5a2c046bda2993cb1b5d224c570797b2f5a035a6a5490b4df2dd59f0" - } - ], - "definition_digest": "sha256:62e778b14549fde5ecff30ac1483402d11a9429395ba4cfb97f7f0bb91f1ee43" - } - }, - { - "vulnerability_id": "CVE-2026-5928", - "package": "libc6", - "installed_version": "2.41-12+deb13u3", - "severity": "High", - "status": "accepted_risk", - "owner": "@jeremi", - "rationale": "Debian classifies the Trixie issue as minor/no-DSA and has no Trixie fix. The reviewed v0.30.0 Linux AMD64 Mint local reproduction from rehearsal run 34673966073 imports no wide-input or ungetwc function and embeds no affected dynamic-resolution name, so no vulnerable wide-character pushback path was identified in the reviewed bytes. A candidate must retain the complete ordered reference rootfs layers, the production OCI process contract, and the Syft-bound reviewed executable, loader, libc, libm and libpthread file digests.", - "reviewed_at": "2026-09-12", - "expires_at": "2026-09-18", - "invalidation_triggers": [ - "candidate_image_identity_mismatch", - "candidate_rootfs_changed", - "component_layer_changed", - "expired", - "exposure_assertion_changed", - "exposure_assertion_false", - "exposure_assertion_unevaluable", - "fix_available", - "material_finding_changed", - "package_version_changed", - "rootfs_evidence_mismatch", - "runtime_config_changed", - "runtime_base_changed" - ], - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "component_layer_id": "sha256:3a7299f559d987305122c7669fc3643095eb0955f8ff4a38c9430d54d0b4452e", - "exposure_assertion": { - "kind": "whole_image_fingerprint_equals", - "reference_image_digest": "sha256:d485eb3b24eff8d0aaea73afdeb28984e38bddc9a52a0512228a2e77fbfb7bbe", - "reference_source_revision": "6bdeb66a8e37693ef13e0fa629e551a75f109f0c", - "reference_provenance": "local_reproduction", - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "files": [ - { - "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "sha256": "sha256:438c546d8e8cc48496bf3a95f753051afd9db66a629a74e31a9ded71586b56e0" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libc.so.6", - "sha256": "sha256:fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libm.so.6", - "sha256": "sha256:6d567d53e895273ca14a1f9dc164fc6c8d39aed2f60aa46a733c2784228915f3" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libpthread.so.0", - "sha256": "sha256:85e21f7dba0394411d00959176fd18b470e575b0b05f1f4f41e5636802ce0500" - }, - { - "path": "/usr/local/bin/mint", - "sha256": "sha256:31f5cebf5a2c046bda2993cb1b5d224c570797b2f5a035a6a5490b4df2dd59f0" - } - ], - "definition_digest": "sha256:62e778b14549fde5ecff30ac1483402d11a9429395ba4cfb97f7f0bb91f1ee43" - } - }, - { - "vulnerability_id": "CVE-2026-5435", - "package": "libc6", - "installed_version": "2.41-12+deb13u3", - "severity": "High", - "status": "accepted_risk", - "owner": "@jeremi", - "rationale": "Debian classifies the Trixie issue as minor/no-DSA and has no Trixie fix. The reviewed v0.30.0 Linux AMD64 Mint local reproduction from rehearsal run 34673966073 imports or dynamically resolves none of the deprecated resolver-printing interfaces, including ns_printrrf, ns_printrr, ns_sprintrrf, ns_sprintrr and fp_nquery. Ordinary resolver initialization does not enter the vulnerable TSIG diagnostic-printing path. A candidate must retain the complete ordered reference rootfs layers, the production OCI process contract, and the Syft-bound reviewed executable, loader, libc, libm and libpthread file digests.", - "reviewed_at": "2026-09-12", - "expires_at": "2026-09-18", - "invalidation_triggers": [ - "candidate_image_identity_mismatch", - "candidate_rootfs_changed", - "component_layer_changed", - "expired", - "exposure_assertion_changed", - "exposure_assertion_false", - "exposure_assertion_unevaluable", - "fix_available", - "material_finding_changed", - "package_version_changed", - "rootfs_evidence_mismatch", - "runtime_config_changed", - "runtime_base_changed" - ], - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "component_layer_id": "sha256:3a7299f559d987305122c7669fc3643095eb0955f8ff4a38c9430d54d0b4452e", - "exposure_assertion": { - "kind": "whole_image_fingerprint_equals", - "reference_image_digest": "sha256:d485eb3b24eff8d0aaea73afdeb28984e38bddc9a52a0512228a2e77fbfb7bbe", - "reference_source_revision": "6bdeb66a8e37693ef13e0fa629e551a75f109f0c", - "reference_provenance": "local_reproduction", - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "files": [ - { - "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "sha256": "sha256:438c546d8e8cc48496bf3a95f753051afd9db66a629a74e31a9ded71586b56e0" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libc.so.6", - "sha256": "sha256:fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libm.so.6", - "sha256": "sha256:6d567d53e895273ca14a1f9dc164fc6c8d39aed2f60aa46a733c2784228915f3" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libpthread.so.0", - "sha256": "sha256:85e21f7dba0394411d00959176fd18b470e575b0b05f1f4f41e5636802ce0500" - }, - { - "path": "/usr/local/bin/mint", - "sha256": "sha256:31f5cebf5a2c046bda2993cb1b5d224c570797b2f5a035a6a5490b4df2dd59f0" - } - ], - "definition_digest": "sha256:62e778b14549fde5ecff30ac1483402d11a9429395ba4cfb97f7f0bb91f1ee43" - } - }, - { - "vulnerability_id": "CVE-2026-85091", - "package": "zlib1g", - "installed_version": "1:1.3.dfsg+really1.3.1-1+b1", - "severity": "High", - "status": "accepted_risk", - "owner": "@jeremi", - "rationale": "Debian Trixie has no fixed package and Grype reports no fix. The reviewed v0.30.0 Linux AMD64 Mint local reproduction from rehearsal run 34673966073 does not link libz or embed libz.so, gz_vacate, gzwrite, gzfwrite, gzprintf or gzvprintf in its executable. Its reviewed dynamic lookups do not select those functions, so no vulnerable stalled gzip-write followed by formatted-output path was identified. A candidate must retain the complete ordered reference rootfs layers, the production OCI process contract, and the Syft-bound reviewed executable, loader, libc, libm and libpthread file digests.", - "reviewed_at": "2026-09-12", - "expires_at": "2026-09-18", - "invalidation_triggers": [ - "candidate_image_identity_mismatch", - "candidate_rootfs_changed", - "component_layer_changed", - "expired", - "exposure_assertion_changed", - "exposure_assertion_false", - "exposure_assertion_unevaluable", - "fix_available", - "material_finding_changed", - "package_version_changed", - "rootfs_evidence_mismatch", - "runtime_config_changed", - "runtime_base_changed" - ], - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "component_layer_id": "sha256:e4ba966d7f0527dfe0fcb559e4e18d4da42c4e6beae924719255e0dedb554ed0", - "exposure_assertion": { - "kind": "whole_image_fingerprint_equals", - "reference_image_digest": "sha256:d485eb3b24eff8d0aaea73afdeb28984e38bddc9a52a0512228a2e77fbfb7bbe", - "reference_source_revision": "6bdeb66a8e37693ef13e0fa629e551a75f109f0c", - "reference_provenance": "local_reproduction", - "runtime_definition_digest": "sha256:12453c7f99649036bbe8d99154d84b6c57598c62cbb7bcf041fd633b6702814e", - "files": [ - { - "path": "/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "sha256": "sha256:438c546d8e8cc48496bf3a95f753051afd9db66a629a74e31a9ded71586b56e0" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libc.so.6", - "sha256": "sha256:fa430b8f298f817a266046af84a77533185ad6fc4406c7d3787b5a0a0c207826" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libm.so.6", - "sha256": "sha256:6d567d53e895273ca14a1f9dc164fc6c8d39aed2f60aa46a733c2784228915f3" - }, - { - "path": "/usr/lib/x86_64-linux-gnu/libpthread.so.0", - "sha256": "sha256:85e21f7dba0394411d00959176fd18b470e575b0b05f1f4f41e5636802ce0500" - }, - { - "path": "/usr/local/bin/mint", - "sha256": "sha256:31f5cebf5a2c046bda2993cb1b5d224c570797b2f5a035a6a5490b4df2dd59f0" - } - ], - "definition_digest": "sha256:62e778b14549fde5ecff30ac1483402d11a9429395ba4cfb97f7f0bb91f1ee43" - } - } - ] -} From 925437fc0878dbc4c84eb325db9237f5757087f2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 09:53:34 +0700 Subject: [PATCH 100/120] fix(casework): bind task grants to approver roles Signed-off-by: Jeremi Joslin --- .../migrations/0014_task_grants.sql | 3 +- crates/registry-casework/src/task_grants.rs | 32 +++-- .../src/task_grants/http_tests.rs | 133 +++++++++++++++++- .../src/task_grants/tests.rs | 2 +- .../tests/task_grant_invalidation.rs | 2 +- 5 files changed, 157 insertions(+), 15 deletions(-) diff --git a/crates/registry-casework/migrations/0014_task_grants.sql b/crates/registry-casework/migrations/0014_task_grants.sql index 666e098092..e894ae14ff 100644 --- a/crates/registry-casework/migrations/0014_task_grants.sql +++ b/crates/registry-casework/migrations/0014_task_grants.sql @@ -17,6 +17,7 @@ CREATE TABLE casework_task_grants ( approver_issuer text NOT NULL, approver_subject text NOT NULL, approver_profile text NOT NULL, + approver_role text NOT NULL CHECK (approver_role IN ('staff','supervisor')), idempotency_key text NOT NULL, request_hash text NOT NULL, record jsonb NOT NULL CHECK (octet_length(record::text) <= 65536), @@ -60,7 +61,7 @@ BEGIN SELECT 1 FROM casework_memberships m JOIN casework_queue_service q ON q.team_id=m.team_id WHERE m.issuer=g.approver_issuer AND m.subject=g.approver_subject - AND m.membership_kind IN ('staff','supervisor') + AND m.membership_kind=g.approver_role AND g.record->'template'->'eligibleTeams' ? m.team_id AND q.queue_id=i.queue_id ) diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index b5d0483bc9..9f496d91af 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -16,14 +16,24 @@ pub(crate) struct StoredTaskGrant { pub invalidated: bool, } +fn membership_kind(role: CaseworkRole) -> Option<&'static str> { + match role { + CaseworkRole::Staff => Some("staff"), + CaseworkRole::Supervisor => Some("supervisor"), + CaseworkRole::Administrator | CaseworkRole::Requester => None, + } +} + async fn eligible( transaction: &Transaction<'_>, actor: &ActorContext, item: &WorkItem, template: &TaskTemplate, ) -> Result { - if !matches!(actor.role, CaseworkRole::Staff | CaseworkRole::Supervisor) - || !template.eligible_profiles.contains(&actor.profile_id) + let Some(membership_kind) = membership_kind(actor.role) else { + return Ok(false); + }; + if !template.eligible_profiles.contains(&actor.profile_id) || item.holder.as_ref() != Some(&actor.principal) || !template.item_states.contains(&item.state) || item.subject.source_id != template.source @@ -32,8 +42,8 @@ async fn eligible( return Ok(false); } let row = transaction.query_one( - "SELECT EXISTS(SELECT 1 FROM casework_memberships m JOIN casework_queue_service q ON q.team_id=m.team_id WHERE m.issuer=$1 AND m.subject=$2 AND m.membership_kind IN ('staff','supervisor') AND m.team_id=ANY($3) AND q.queue_id=$4)", - &[&actor.principal.issuer, &actor.principal.subject, &template.eligible_teams, &item.queue_id], + "SELECT EXISTS(SELECT 1 FROM casework_memberships m JOIN casework_queue_service q ON q.team_id=m.team_id WHERE m.issuer=$1 AND m.subject=$2 AND m.membership_kind=$3 AND m.team_id=ANY($4) AND q.queue_id=$5)", + &[&actor.principal.issuer, &actor.principal.subject, &membership_kind, &template.eligible_teams, &item.queue_id], ).await?; Ok(row.get(0)) } @@ -177,7 +187,8 @@ impl PostgresStore { if serde_json::to_vec(&record)?.len() > 65536 { return Err(StoreError::Invalid); } - transaction.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", &[&grant.id,&item.item_id,&actor.principal.issuer,&actor.principal.subject,&actor.profile_id,&key,&hash,&record,&approved,&expires]).await?; + let approver_role = membership_kind(actor.role).ok_or(StoreError::Forbidden)?; + transaction.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,approver_role,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", &[&grant.id,&item.item_id,&actor.principal.issuer,&actor.principal.subject,&actor.profile_id,&approver_role,&key,&hash,&record,&approved,&expires]).await?; task_event(&transaction, &item, "task_approved", Some(actor), grant.id).await?; transaction.commit().await?; Ok(StoredTaskGrant { @@ -220,18 +231,23 @@ impl PostgresStore { .await?; let Some(row) = transaction .query_opt( - "SELECT * FROM casework_items WHERE item_id=$1 AND erased_at IS NULL FOR UPDATE", - &[&grant.item_id], + "SELECT i.*,g.approver_role FROM casework_items i JOIN casework_task_grants g ON g.item_id=i.item_id WHERE g.grant_id=$1 AND i.erased_at IS NULL FOR UPDATE OF i", + &[&grant.id], ) .await? else { return Ok(false); }; let item = crate::store::row_to_item(&row)?; + let role = match row.get::<_, &str>("approver_role") { + "staff" => CaseworkRole::Staff, + "supervisor" => CaseworkRole::Supervisor, + _ => return Err(StoreError::Invalid), + }; let actor = ActorContext { principal: grant.approver.clone(), profile_id: grant.approver_profile.clone(), - role: CaseworkRole::Staff, + role, }; let valid = template_active(&transaction, template).await? && eligible(&transaction, &actor, &item, template).await? diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index c9c5bd51f5..9501135766 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -128,11 +128,23 @@ struct Fixture { app: Router, mode: Arc, item: Uuid, + profile_id: &'static str, + template: TaskTemplate, admin: tokio_postgres::Client, schema: String, store: PostgresStore, } async fn fixture(lifetime: u64) -> Fixture { + fixture_for_role(lifetime, CaseworkRole::Staff).await +} +async fn fixture_for_role(lifetime: u64, role: CaseworkRole) -> Fixture { + let (profile_id, scope, membership_kind) = match role { + CaseworkRole::Staff => ("staff", "casework:staff", "staff"), + CaseworkRole::Supervisor => ("supervisor", "casework:supervisor", "supervisor"), + CaseworkRole::Administrator | CaseworkRole::Requester => { + panic!("task approval requires a staff or supervisor role") + } + }; let base = std::env::var("CASEWORK_ASSIGNMENT_TEST_DATABASE_URL") .expect("disposable database is required"); let schema = format!("task_http_{}", Uuid::new_v4().simple()); @@ -158,8 +170,9 @@ async fn fixture(lifetime: u64) -> Fixture { let store = PostgresStore::connect_migration(&config, &secrets).unwrap(); store.migrate().await.unwrap(); std::env::remove_var(name); - let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); - let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); + let template:TaskTemplate=serde_json::from_value(json!({"id":"summary","version":"1","label":"Prepare summary","eligibleTeams":["team"],"eligibleProfiles":[profile_id],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":ISSUER,"subject":"agent"},"client":"agent-client","resource":"urn:breg:test","purpose":"prepare-summary","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"people","operations":["get"]}]},"subjects":{"person_reference":"person-reference"},"lifetimeSeconds":lifetime})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"tasks","version":"1"},"accessProfiles":[{"id":profile_id,"principalClaim":"sub","requiredScopes":[scope],"role":profile_id}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Test source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template]})).unwrap(); + let template = project.task_templates[0].clone(); store .activate_task_templates(&project.task_templates) .await @@ -171,7 +184,7 @@ async fn fixture(lifetime: u64) -> Fixture { ) .await .unwrap(); - db.execute("INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,'human','staff')",&[&ISSUER]).await.unwrap(); + db.execute("INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,'human',$2)",&[&ISSUER,&membership_kind]).await.unwrap(); db.execute( "INSERT INTO casework_queue_service(queue_id,team_id,revision) VALUES('review','team',1)", &[], @@ -241,6 +254,8 @@ async fn fixture(lifetime: u64) -> Fixture { app, mode, item, + profile_id, + template, admin, schema, store, @@ -261,7 +276,7 @@ async fn request( .header("authorization", format!("Bearer {token}")); if human { req = req - .header(CASEWORK_PROFILE_HEADER, "staff") + .header(CASEWORK_PROFILE_HEADER, f.profile_id) .header(SOURCE_PROFILE_HEADER, "source-reader"); } if let Some(key) = key { @@ -290,6 +305,26 @@ async fn request( (status, serde_json::from_slice(&bytes).unwrap()) } +async fn replace_membership(store: &PostgresStore, kind: &str) { + let mut db = store.client().await.unwrap(); + let transaction = db.transaction().await.unwrap(); + transaction + .execute( + "UPDATE casework_memberships SET membership_kind=$1", + &[&kind], + ) + .await + .unwrap(); + transaction + .execute( + "UPDATE casework_meta SET directory_revision=directory_revision+1", + &[], + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); +} + #[tokio::test] async fn task_http_approval_assertion_status_and_revocation_enforce_current_authority() { let f = fixture(900).await; @@ -537,6 +572,96 @@ async fn task_http_approval_assertion_status_and_revocation_enforce_current_auth .unwrap(); } +#[tokio::test] +async fn supervisor_task_grant_requires_continued_supervisor_membership() { + let f = fixture_for_role(900, CaseworkRole::Supervisor).await; + let supervisor = token("human", "human-client", "human", "casework:supervisor"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let resource = token( + "resource", + "breg-status", + "service", + "casework:grants:status", + ); + replace_membership(&f.store, "staff").await; + let actor = ActorContext { + principal: IssuerPrincipal { + issuer: ISSUER.into(), + subject: "human".into(), + }, + profile_id: "supervisor".into(), + role: CaseworkRole::Supervisor, + }; + assert!( + !f.store + .eligible_task_template(&actor, f.item, &f.template) + .await + .unwrap(), + "a supervisor profile cannot approve through staff membership" + ); + replace_membership(&f.store, "supervisor").await; + let approval_path = format!("/v1/work-items/{}/task-grants", f.item); + let (status, grant) = request( + &f, + "POST", + &approval_path, + &supervisor, + true, + Some(json!({"templateId":"summary","templateVersion":"1"})), + Some("supervisor-approval"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let grant_id = grant["id"].as_str().unwrap(); + let assertion_path = format!("/v1/task-grants/{grant_id}/assertion"); + let status_path = format!("/v1/task-grants/{grant_id}/status"); + + replace_membership(&f.store, "supervisor").await; + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::OK, + "an unchanged supervisor membership must preserve the grant" + ); + let (status, active) = request(&f, "GET", &status_path, &resource, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{active}"); + assert_eq!(active["active"], true); + + replace_membership(&f.store, "staff").await; + assert_eq!( + request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + let (status, inactive) = request(&f, "GET", &status_path, &resource, false, None, None).await; + assert_eq!(status, StatusCode::OK, "{inactive}"); + assert_eq!(inactive["active"], false); + assert!(inactive.get("grant").is_none()); + let invalidation_reason: Option = f + .store + .client() + .await + .unwrap() + .query_one( + "SELECT invalidation_reason FROM casework_task_grants WHERE grant_id=$1", + &[&Uuid::parse_str(grant_id).unwrap()], + ) + .await + .unwrap() + .get(0); + assert_eq!(invalidation_reason.as_deref(), Some("eligibility")); + replace_membership(&f.store, "supervisor").await; + let (_, still_inactive) = request(&f, "GET", &status_path, &resource, false, None, None).await; + assert_eq!(still_inactive["active"], false); + + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn status_cannot_outlive_grant_deadline_during_source_read() { let f = fixture(1).await; diff --git a/crates/registry-casework/src/task_grants/tests.rs b/crates/registry-casework/src/task_grants/tests.rs index 81bded5ef2..20c3d34781 100644 --- a/crates/registry-casework/src/task_grants/tests.rs +++ b/crates/registry-casework/src/task_grants/tests.rs @@ -53,7 +53,7 @@ async fn template_versions_are_immutable_and_retirement_cannot_revive_existing_g let item = Uuid::new_v4(); let grant = Uuid::new_v4(); db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,revision,first_observed_at,updated_at) VALUES($1,'source','request','request-1','review','review-1',$2,'claimed','review',1,now(),now())", &[&item,&json!({"sourceRevision":"1","version":"1","generation":"1"})]).await.unwrap(); - db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff','key','hash',$3,now(),now()+interval '900 seconds')", &[&grant,&item,&json!({"template":template})]).await.unwrap(); + db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,approver_role,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff','staff','key','hash',$3,now(),now()+interval '900 seconds')", &[&grant,&item,&json!({"template":template})]).await.unwrap(); store.activate_task_templates(&[]).await.unwrap(); let invalidated: bool = db .query_one( diff --git a/crates/registry-casework/tests/task_grant_invalidation.rs b/crates/registry-casework/tests/task_grant_invalidation.rs index db58455f0b..089f095418 100644 --- a/crates/registry-casework/tests/task_grant_invalidation.rs +++ b/crates/registry-casework/tests/task_grant_invalidation.rs @@ -45,7 +45,7 @@ async fn grant(db: &Client) -> (Uuid, Uuid) { let binding = json!({"sourceRevision":"1","version":"proposal-1","generation":"source-1"}); db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,holder_issuer,holder_subject,revision,first_observed_at,updated_at) VALUES($1,'source','request',$2,'review',$2,$3,'claimed','review','https://issuer.test','human',1,now(),now())", &[&item,&item.to_string(),&binding]).await.unwrap(); let record = json!({"template":{"itemStates":["claimed","waiting_applicant"],"itemKinds":["request"],"source":"source","eligibleTeams":["team"]},"proposal":{"version":"proposal-1","generation":"source-1","integrity":null},"subjects":{"person_reference":"synthetic-person"}}); - db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff',$3,'synthetic-hash',$4,now(),now()+interval '900 seconds')", &[&grant,&item,&grant.to_string(),&record]).await.unwrap(); + db.execute("INSERT INTO casework_task_grants(grant_id,item_id,approver_issuer,approver_subject,approver_profile,approver_role,idempotency_key,request_hash,record,approved_at,expires_at) VALUES($1,$2,'https://issuer.test','human','staff','staff',$3,'synthetic-hash',$4,now(),now()+interval '900 seconds')", &[&grant,&item,&grant.to_string(),&record]).await.unwrap(); (item, grant) } From 048976bf36724b55b5874080c7d314b502695cac Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:15:51 +0700 Subject: [PATCH 101/120] fix(evidence): require explicit standing agent authority Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/config.rs | 4 ++ crates/registry-evidence/src/selector.rs | 15 +++++- .../tests/selector_conformance.rs | 52 +++++++++++++++++++ .../contracts/authority-context.schema.yaml | 2 +- .../evidence/contracts/bundle.schema.yaml | 5 ++ .../deployment-projects/CONFIG.md | 7 +++ 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index 862f70cf3c..be1bf2cc39 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -3649,6 +3649,10 @@ pub enum PreparationChannelPolicy { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AuthorityProfile { pub kind: AuthorityKind, + /// Restrict this profile to the verified actor kind. Required for agents + /// exercising standing authority without an authenticated task grant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor_kind: Option, pub requester_tags: Vec, /// Verified OAuth clients allowed to exercise a grant-bound authority /// path. Required only when one of this profile's subjects is sourced from diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index b4375612e8..7f1debef28 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -438,6 +438,15 @@ pub fn match_entitlement( let mut matched = Vec::new(); for (authority_profile, authority) in bundle.config.authority_profiles.iter() { + if authority + .actor_kind + .is_some_and(|required| required != context.actor_kind()) + || (context.actor_kind() == ActorKind::Agent + && !has_task_grant + && authority.actor_kind != Some(ActorKind::Agent)) + { + continue; + } if !authority.requester_tags.iter().all(|required| { context .requester_tags() @@ -871,7 +880,11 @@ pub fn resolve_offline_fixture_authorization( .map_err(|_| AuthorizationError::Unauthorized)?; (ActorKind::Agent, Some(client.as_str()), Ok(grant)) } else { - (ActorKind::Service, None, Ok(None)) + ( + authority.actor_kind.unwrap_or(ActorKind::Service), + None, + Ok(None), + ) }; let context = AuthenticatedContext::offline_fixture_context( authority.requester_tags.clone(), diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index 4af05a7e54..5946f8d6ba 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -961,6 +961,40 @@ async fn all_runtime_selector_negatives_fail_closed_before_source_access() { ); } +#[tokio::test] +async fn standing_agent_requires_an_explicit_agent_profile() { + let service = prepare_service(false).await; + let request = classification_request(values([( + "record_reference", + SelectorValue::String("synthetic-record-001".to_owned()), + )])); + assert_authorization_error( + &service, + &access_token(json!({"registry_actor_kind": "agent"})), + &request, + AuthorizationError::Unauthorized, + ) + .await; + assert!(service.server.received_requests().await.unwrap().is_empty()); + + let bound = prepare_service_for_actor(false, Some("agent")).await; + assert!(bound + .authorize( + &access_token(json!({"registry_actor_kind": "agent"})), + &request + ) + .await + .is_ok()); + assert_authorization_error( + &bound, + &access_token(json!({"registry_actor_kind": "service"})), + &request, + AuthorizationError::Unauthorized, + ) + .await; + assert!(bound.server.received_requests().await.unwrap().is_empty()); +} + #[tokio::test] async fn task_grant_context_is_bound_before_selector_or_source_access() { let service = prepare_service(false).await; @@ -1075,6 +1109,13 @@ fn configuration_selector_negatives_are_rejected_at_immutable_bundle_load() { } async fn prepare_service(write_source_secret: bool) -> PreparedService { + prepare_service_for_actor(write_source_secret, None).await +} + +async fn prepare_service_for_actor( + write_source_secret: bool, + profile_actor: Option<&str>, +) -> PreparedService { let temporary = tempfile::tempdir().expect("temporary selector conformance root"); let bundle_root = temporary.path().join("bundle"); let secret_root = temporary.path().join("secrets"); @@ -1088,6 +1129,17 @@ async fn prepare_service(write_source_secret: bool) -> PreparedService { copy_tree(&selector_bundle_root(), &bundle_root); let server = MockServer::start().await; rewrite_source_origin(&bundle_root, &server.uri()); + if let Some(actor) = profile_actor { + let config_path = bundle_root.join("evidence.yaml"); + let config = fs::read_to_string(&config_path).unwrap(); + let marker = " reviewed-caseworker-v1:\n"; + assert!(config.contains(marker)); + fs::write( + &config_path, + config.replacen(marker, &format!("{marker} actorKind: {actor}\n"), 1), + ) + .unwrap(); + } write_secret(&secret_root, "audit-key", AUDIT_KEY); write_secret(&secret_root, "binding-key", BINDING_KEY); write_secret(&secret_root, "signing-key", EVIDENCE_PRIVATE_JWK.as_bytes()); diff --git a/products/evidence/contracts/authority-context.schema.yaml b/products/evidence/contracts/authority-context.schema.yaml index 0718807eb8..d98774f051 100644 --- a/products/evidence/contracts/authority-context.schema.yaml +++ b/products/evidence/contracts/authority-context.schema.yaml @@ -135,7 +135,7 @@ authorization_decision: - complete set of role, selector profile, and value origin tuples matching_rule: One entitlement must cover the complete request; permissions are never unioned across entitlements. principal_rule: Principal derives only from the one configured validated claim; a missing claim denies with no client_id, azp, header, or request fallback. - grant_rule: A grant identifier is context, never authority by possession. A present task grant is accepted only for an agent and only when its immutable principal, verified client, verified resource, trusted source issuer, authority, purpose, Evidence requirement bound, and effective token/grant deadline all match. An invalid present grant cannot fall back to a standing entitlement. + grant_rule: A grant identifier is context, never authority by possession. A present task grant is accepted only for an agent and only when its immutable principal, verified client, verified resource, trusted source issuer, authority, purpose, Evidence requirement bound, and effective token/grant deadline all match. An invalid present grant cannot fall back to a standing entitlement. An agent without a grant matches only an entitlement whose authority profile explicitly declares actorKind agent. Any declared actorKind must match the verified token. subject_binding_rule: Permission to serialize a response is not permission to issue under a binding mode. The one matched grant must name a holder-bound path explicitly before a holder-bound assertion may be issued on it; a grant widened to permit the SD-JWT VC serialization gains no binding-mode permission from that. Both halves are checked against the same one grant, because permissions are never unioned across grants, and the single denial that follows a failure of either does not say which one failed. selector_claim_rule: Context-derived and grant-derived selector values resolve only through the complete configured valueClaims map over the already verified token; request-derived subjects prohibit valueClaims. authenticated_grant_rule: The complete shared grant claim set must exist, grantAuthority must exactly equal the matched authority-profile identifier, and the same entitlement must cover the complete request. Selector values come only from the verified token and never the caller request. diff --git a/products/evidence/contracts/bundle.schema.yaml b/products/evidence/contracts/bundle.schema.yaml index 93f4314347..1858357d31 100644 --- a/products/evidence/contracts/bundle.schema.yaml +++ b/products/evidence/contracts/bundle.schema.yaml @@ -2148,6 +2148,11 @@ $defs: - consent - delegated - explicit-request + actorKind: + description: >- + Restrict this authority profile to the verified actor kind. An agent without + a task grant must use a profile explicitly bound to agent. + enum: [human, agent, service] requesterTags: description: >- Complete tag set the claim named by `authentication.requesterTagsClaim` must contain diff --git a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md index 599941063c..d33db387ad 100644 --- a/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md +++ b/products/evidence/reference/request-adapter/deployment-projects/CONFIG.md @@ -212,6 +212,12 @@ matching, fuzzy scoring, or candidate selection. ### Authority profiles +An optional `actorKind` binds the authority profile to `human`, `agent`, or +`service` in the verified token. An agent without a task grant must use a profile +that explicitly declares `actorKind: agent`; profiles without that binding do +not grant standing agent authority. A present but invalid task grant never falls +back to a standing profile. + An authority profile has a `kind` of `statutory`, `organizational`, `consent`, `delegated`, or `explicit-request`, non-empty `requesterTags`, and grants. Each grant binds one exact `requirement`, `purpose`, @@ -1533,6 +1539,7 @@ authentication.tokenTypes authentication.tokenTypes[] authorityProfiles authorityProfiles.* +authorityProfiles.*.actorKind authorityProfiles.*.grantSourceIssuer authorityProfiles.*.grants authorityProfiles.*.grants[] From 8ec7ba41160c6aa3940a388775a3d3877f310654 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:26:02 +0700 Subject: [PATCH 102/120] fix(oid4vci): reject grants under configured claim names Signed-off-by: Jeremi Joslin --- .../src/authorizer.rs | 16 ++- .../registry-evidence-oid4vci/src/config.rs | 18 ++++ .../registry-evidence-oid4vci/src/service.rs | 98 +++++++++++++++++++ .../tests/inji_interoperability.rs | 1 + .../tests/offer_authorization.rs | 3 + .../docs/configure/evidence-oid4vci.mdx | 8 ++ 6 files changed, 140 insertions(+), 4 deletions(-) diff --git a/crates/registry-evidence-oid4vci/src/authorizer.rs b/crates/registry-evidence-oid4vci/src/authorizer.rs index 2fbdf99cfd..0a2f9f0faa 100644 --- a/crates/registry-evidence-oid4vci/src/authorizer.rs +++ b/crates/registry-evidence-oid4vci/src/authorizer.rs @@ -68,6 +68,7 @@ pub trait OfferAuthorizer: Send + Sync { #[derive(Debug)] pub struct MintResourceServer { verifier: Arc, + claims: ClaimNames, /// Scopes every offer token must carry. Checked against the verified /// token's scope set after verification, with the same closed refusal a /// refused client gets, before any offer is stored or Evidence contacted. @@ -91,6 +92,7 @@ impl MintResourceServer { Self { verifier: Arc::new(TokenVerifier::new(verifier_profile(config), fetcher)), required_scopes, + claims: config.claims.clone(), } } @@ -102,9 +104,18 @@ impl MintResourceServer { Self { verifier, required_scopes: Vec::new(), + claims: ClaimNames::default(), } } + /// Apply the issuer's contextual claim names when supplying a verifier + /// directly. Invalid mappings are refused during authorization. + #[must_use] + pub fn with_claim_names(mut self, claims: ClaimNames) -> Self { + self.claims = claims; + self + } + /// State scopes every offer token must carry, for a deployment that built /// its verifier another way but still wants the scope gate. #[must_use] @@ -183,10 +194,7 @@ impl OfferAuthorizer for MintResourceServer { .duration_since(std::time::UNIX_EPOCH) .map_err(|_| AuthorizationError::Refused)? .as_secs(); - if !matches!( - grant_claims(&verified.claims, &ClaimNames::default(), now), - Ok(None) - ) { + if !matches!(grant_claims(&verified.claims, &self.claims, now), Ok(None)) { // A wallet offer creates a deferred bearer lifecycle whose // later redemption cannot recheck the task authority. No // complete, partial, malformed, or expired task grant may diff --git a/crates/registry-evidence-oid4vci/src/config.rs b/crates/registry-evidence-oid4vci/src/config.rs index 8d404a9f0e..c954eae09b 100644 --- a/crates/registry-evidence-oid4vci/src/config.rs +++ b/crates/registry-evidence-oid4vci/src/config.rs @@ -265,6 +265,10 @@ fn default_maximum_token_lifetime_seconds() -> u64 { #[derive(Debug, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct OfferAuthorizationConfig { + /// Contextual claim names used by this offer-token issuer. Keep these + /// aligned with Evidence when both verify tokens from the same deployment. + #[serde(default)] + pub claims: registry_platform_oidc::ClaimNames, /// The authorization server that issues offer tokens, compared exactly /// against a token's `iss`. pub issuer: String, @@ -291,6 +295,9 @@ pub struct OfferAuthorizationConfig { impl OfferAuthorizationConfig { fn validate(&self) -> Result<(), ConfigError> { + self.claims.validate().map_err(|_| { + ConfigError::Invalid("offer contextual claim names must be valid and distinct") + })?; if self.audiences.is_empty() { return Err(ConfigError::Invalid( "the offer endpoint must state at least one audience", @@ -900,6 +907,17 @@ store: } } + #[test] + fn offer_claim_names_load_and_reject_shadowing() { + let configured = VALID.replace("offers:\n", "offers:\n claims:\n grantId: task_id\n"); + let config = load_from(&configured).expect("custom contextual claim names load"); + assert_eq!(config.offers.claims.grant_id, "task_id"); + for name in ["sub", "registry_grant_authority"] { + let invalid = configured.replace("grantId: task_id", &format!("grantId: {name}")); + assert!(matches!(load_from(&invalid), Err(ConfigError::Invalid(_)))); + } + } + #[test] fn the_offer_authorization_block_is_required_and_is_not_the_client_identity() { // The resource-server half and the client half are separate documents. diff --git a/crates/registry-evidence-oid4vci/src/service.rs b/crates/registry-evidence-oid4vci/src/service.rs index 32191c437e..5982d61501 100644 --- a/crates/registry-evidence-oid4vci/src/service.rs +++ b/crates/registry-evidence-oid4vci/src/service.rs @@ -1703,6 +1703,104 @@ mod tests { } } + #[tokio::test] + async fn offer_endpoint_refuses_malformed_expired_and_renamed_grants() { + use crate::authorizer::MintResourceServer; + use crate::config::{AccessTokenAlgorithm, ValidationMode}; + use registry_platform_oidc::ClaimNames; + + let directory = tempfile::tempdir().unwrap(); + let key = private_jwk("offer-auth"); + let public = public_jwk(&key); + let jwks = json!({"keys": [public]}); + let key_server = TestServer::builder() + .http_transport() + .build(Router::new().route( + "/jwks", + axum::routing::get(move || { + let jwks = jwks.clone(); + async move { axum::Json(jwks) } + }), + )); + let issuer_url = key_server + .server_address() + .unwrap() + .to_string() + .trim_end_matches('/') + .to_owned(); + let now = chrono::Utc::now().timestamp(); + for renamed in [false, true] { + let mut config = load_deployment(directory.path()); + config.offers.algorithms = vec![AccessTokenAlgorithm::ES256]; + config.offers.issuer = issuer_url.clone(); + config.offers.jwks_uri = format!("{issuer_url}/jwks"); + let mut names = ClaimNames::default(); + if renamed { + names.grant_id = "task_id".into(); + names.grant_authority = "task_authority".into(); + names.grant_source_issuer = "task_source".into(); + names.grant_client = "task_client".into(); + names.grant_resource = "task_resource".into(); + names.grant_exp = "task_exp".into(); + names.grant_bounds = "task_bounds".into(); + names.approver = "task_approver".into(); + } + config.offers.claims = names.clone(); + let authorizer = MintResourceServer::from_config( + &config.offers, + ValidationMode::SupervisedLocalDevelopment, + ); + let issuer = Arc::new(RecordingIssuer::new()); + let server = TestServer::new(build_app(Arc::new(DeliveryService::with_halves( + config, + Arc::new(authorizer), + issuer.clone(), + )))); + for state in ["ordinary", "malformed", "expired", "live"] { + let mut claims = json!({ + "iss":issuer_url, "aud":"https://wallet.example.org", + "sub":"offer-client", "client_id":"offer-client", "iat":now, "exp":now+300, + "registry_actor_kind": if state == "ordinary" { "service" } else { "agent" }, "registry_purpose":"delivery", + }); + if state != "ordinary" { + claims[&names.grant_id] = json!("grant-a"); + claims[&names.grant_authority] = json!("authority-a"); + claims[&names.grant_source_issuer] = json!("https://casework.example.org"); + claims[&names.grant_client] = json!("offer-client"); + claims[&names.grant_resource] = json!("https://wallet.example.org"); + claims[&names.grant_exp] = + json!(if state == "expired" { now } else { now + 300 }); + claims[&names.grant_bounds] = + json!({"type":"evidence","requirement":CONFIGURATION_ID}); + claims[&names.approver] = json!("approver-pseudonym"); + if state == "malformed" { + claims[&names.grant_id] = json!(42); + } + } + let token = proof_jwt_with_header( + &key, + json!({"alg":"ES256","typ":"at+jwt","kid":"offer-auth"}), + claims, + ); + let response = server + .post(OFFERS_PATH) + .add_header("authorization", format!("Bearer {token}")) + .json(&offer_body(false)) + .await; + assert_eq!( + response.status_code(), + if state == "ordinary" { + StatusCode::CREATED + } else { + StatusCode::UNAUTHORIZED + }, + "renamed={renamed}, state={state}" + ); + } + assert!(issuer.requests().is_empty()); + } + } + #[tokio::test] async fn the_offer_boundary_shares_no_code_path_with_the_mint_client_identity() { // The client key on disk is not a usable identity, so the client half diff --git a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs index e8867b6fd9..95e5234ba8 100644 --- a/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs +++ b/crates/registry-evidence-oid4vci/tests/inji_interoperability.rs @@ -736,6 +736,7 @@ fn task_bound_offer_access_token(issuer: &FixtureIssuer) -> String { "jti": "synthetic-task-bound-offer-authorization", "registry_actor_kind": "agent", "registry_grant_id": "synthetic-task-grant", + "registry_approver": "h:synthetic-approver", "registry_grant_authority": "synthetic-casework-authority", "registry_grant_source_issuer": "https://casework.example.org", "registry_grant_client": "tutorial-operator", diff --git a/crates/registry-evidence-oid4vci/tests/offer_authorization.rs b/crates/registry-evidence-oid4vci/tests/offer_authorization.rs index 2d501ae0ae..f66f9527f3 100644 --- a/crates/registry-evidence-oid4vci/tests/offer_authorization.rs +++ b/crates/registry-evidence-oid4vci/tests/offer_authorization.rs @@ -70,6 +70,7 @@ fn resource_server(config: &OfferAuthorizationConfig, key_set: &Value) -> MintRe verifier_profile(config), fetcher, ))) + .with_claim_names(config.claims.clone()) .with_required_scopes(config.required_scopes.clone().unwrap_or_default()) } @@ -79,6 +80,7 @@ fn offer_config( algorithm: AccessTokenAlgorithm, ) -> OfferAuthorizationConfig { OfferAuthorizationConfig { + claims: Default::default(), issuer: issuer.to_owned(), jwks_uri: format!("{issuer}/oauth2/jwks"), audiences: vec![audience.to_owned()], @@ -169,6 +171,7 @@ async fn task_bound_and_partial_grants_cannot_create_deferred_wallet_offers() { let (task_bound, keys) = signed_offer_fixture(json!({ "registry_actor_kind":"agent", "registry_grant_id":"grant-a", + "registry_approver":"approver-pseudonym", "registry_grant_authority":"authority-a", "registry_grant_source_issuer":"https://casework.example", "registry_grant_client":"offer-caller", diff --git a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx index 73f1b35fcb..f5fb388ee8 100644 --- a/docs/site/src/content/docs/configure/evidence-oid4vci.mdx +++ b/docs/site/src/content/docs/configure/evidence-oid4vci.mdx @@ -202,6 +202,14 @@ Set `offers.requiredScopes` when the issuer grants a dedicated offer capability. scope must occur in the verified offer token before the adapter creates an offer. {/* Evidence: crates/registry-evidence-oid4vci/src/{config,authorizer}.rs required_scopes OfferAuthorizer */} +Set `offers.claims` when the offer issuer uses custom contextual claim names. +It accepts the same claim-name mapping as Evidence's `authentication.claims`; +omitted members use the standard `registry_*` names. Keep both configurations +aligned with the issuer. Names must be distinct and cannot shadow standard JWT +claims. Offers refuse live, expired, partial, and malformed task grants under +the configured names because later wallet redemption cannot recheck their authority. +{/* Evidence: crates/registry-evidence-oid4vci/src/{config,authorizer}.rs OfferAuthorizationConfig MintResourceServer::authorize; crates/registry-platform-oidc/src/authorization_claims.rs ClaimNames::validate */} + `validationMode` defaults to `strict`, which requires every published and called origin (`credentialIssuer`, `evidence.baseUrl`, `tokenClient.tokenEndpoint`, the client assertion audience, `offers.issuer`, and `offers.jwksUri`) to be an absolute `https` URL with a host and no embedded From 3faaf579d87c2b25c06a3c3d603b0388a3e037e5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:26:02 +0700 Subject: [PATCH 103/120] test(oauth): document explicit exchange scope narrowing Signed-off-by: Jeremi Joslin --- .../src/client/private_key_jwt.rs | 29 +++++++++++++++++++ crates/registry-thunderid-tooling/README.md | 9 ++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs index abe93bd240..ca195bd6b6 100644 --- a/crates/registry-platform-httputil/src/client/private_key_jwt.rs +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -1683,6 +1683,35 @@ mod tests { assert!(server.received_requests().await.unwrap().is_empty()); } + #[tokio::test] + async fn exchange_requires_every_requested_scope_even_when_the_issuer_narrows() { + for (scopes, accepted) in [ + (vec!["records:read", "records:write"], false), + (vec!["records:read"], true), + ] { + let server = token_endpoint_serving(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "scope-test-credential", + "token_type": "Bearer", "expires_in": 300, + "issued_token_type": ACCESS_TOKEN_TYPE, + "scope": "records:read" + }))) + .await; + let client = PrivateKeyJwt::with_clock( + config(endpoint(&server.uri()), client_key(Some(KEY_ID))) + .with_resource("urn:registry:records") + .with_scopes(scopes), + Arc::new(TestClock::new(NOW)), + ) + .unwrap(); + let result = client.exchange("task-grant").await; + if accepted { + assert!(result.is_ok()); + } else { + assert_eq!(result.unwrap_err(), TokenError::ScopeNarrowed); + } + } + } + #[tokio::test] async fn exchange_rejects_missing_or_wrong_issued_type_and_narrowed_scope() { for (issued_type, scope) in [ diff --git a/crates/registry-thunderid-tooling/README.md b/crates/registry-thunderid-tooling/README.md index 101222261b..7c71faade8 100644 --- a/crates/registry-thunderid-tooling/README.md +++ b/crates/registry-thunderid-tooling/README.md @@ -143,9 +143,12 @@ clients: The OAuth client must already be registered with that key and permitted to exchange assertions from the configured Casework authority. Before approval, its client-credentials permission is only `casework:grants:assert` at the -bootstrap resource. The requested resource and scopes above are fixed ceilings; -the real Casework assertion supplies immutable authority, subjects and task -bounds, and the issuer enforces the scope subset. +bootstrap resource. The resource and scopes above are the exact request sent on +each exchange. The Casework assertion supplies immutable authority, subjects and +task bounds, and the issuer limits the scopes it grants. The client also requires +every requested scope in a stated response scope: a narrower response is refused +with `ScopeNarrowed`. Configure only the scopes this task actually needs; the +client does not silently retry with fewer permissions. Each invocation acquires a fresh bootstrap token, requests a short-lived signed Casework assertion, and performs uncached RFC 8693 exchange. Only the final bearer From c14c5672a675b5e4e38faa6bf1125863ae291d42 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:30:42 +0700 Subject: [PATCH 104/120] fix(breg): configure task status assertion audience Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/task_grant/config.rs | 3 + crates/registry-breg/src/task_grant/tests.rs | 94 ++++++++++++++----- .../src/task_grants/local_session_tests.rs | 47 ++++------ products/breg/TASK_GRANTS.md | 9 +- .../generated/runtime/runtime.schema.json | 4 + .../identifiers/generated/catalog.v1.json | 2 +- 6 files changed, 104 insertions(+), 55 deletions(-) diff --git a/crates/registry-breg/src/task_grant/config.rs b/crates/registry-breg/src/task_grant/config.rs index 34caca2de1..a700460105 100644 --- a/crates/registry-breg/src/task_grant/config.rs +++ b/crates/registry-breg/src/task_grant/config.rs @@ -12,6 +12,7 @@ pub struct TaskGrantStatusConfig { pub source_issuer: String, pub base_url: String, pub token_endpoint: String, + pub client_assertion_audience: String, pub client_id: String, pub private_key_ref: String, pub casework_resource: String, @@ -58,6 +59,7 @@ impl TaskGrantStatusRegistry { &config.client_id, key, ) + .with_audience(&config.client_assertion_audience) .with_resource(&config.casework_resource) .with_scopes(["casework:grants:status"]); if let Some(ca) = &ca { @@ -140,6 +142,7 @@ mod tests { source_issuer: "https://casework.test".into(), base_url: "https://casework.test".into(), token_endpoint: "https://identity.test/oauth2/token".into(), + client_assertion_audience: "https://identity.test".into(), client_id: "breg-status".into(), private_key_ref: "secret:file/status-key".into(), casework_resource: "urn:casework:test".into(), diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs index 9608e223db..380968dc9f 100644 --- a/crates/registry-breg/src/task_grant/tests.rs +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -3,8 +3,12 @@ use axum::{ extract::State, http::HeaderMap, routing::{get, post}, - Json, Router, + Form, Json, Router, }; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; use std::sync::{ atomic::{AtomicUsize, Ordering}, Mutex, @@ -15,12 +19,30 @@ struct StateData { response: Arc>, status_calls: Arc, token_calls: Arc, + rejected_token_calls: Arc, + client_assertion_audience: String, } -async fn token(State(state): State) -> Json { +async fn token( + State(state): State, + Form(form): Form>, +) -> Result, axum::http::StatusCode> { + let assertion = form + .get("client_assertion") + .ok_or(axum::http::StatusCode::UNAUTHORIZED)?; + let claims = assertion + .split('.') + .nth(1) + .and_then(|value| URL_SAFE_NO_PAD.decode(value).ok()) + .and_then(|value| serde_json::from_slice::(&value).ok()) + .ok_or(axum::http::StatusCode::UNAUTHORIZED)?; + if claims["aud"] != state.client_assertion_audience { + state.rejected_token_calls.fetch_add(1, Ordering::SeqCst); + return Err(axum::http::StatusCode::UNAUTHORIZED); + } state.token_calls.fetch_add(1, Ordering::SeqCst); - Json( + Ok(Json( serde_json::json!({"access_token":"synthetic-status-service","token_type":"Bearer","expires_in":300,"scope":"casework:grants:status"}), - ) + )) } async fn status(State(state): State, headers: HeaderMap) -> Json { assert_eq!( @@ -35,45 +57,71 @@ fn binding() -> TaskGrantBinding { } #[tokio::test] -async fn each_mutating_attempt_reads_fresh_status_and_compares_every_immutable_bound() { +async fn activated_status_client_uses_the_configured_assertion_audience_and_checks_each_attempt() { let binding = binding(); let response = Arc::new(Mutex::new( serde_json::json!({"active":true,"grant":binding}), )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let issuer = format!("http://{address}"); let state = StateData { response: response.clone(), status_calls: Arc::new(AtomicUsize::new(0)), token_calls: Arc::new(AtomicUsize::new(0)), + rejected_token_calls: Arc::new(AtomicUsize::new(0)), + client_assertion_audience: issuer.clone(), }; let app = Router::new() .route("/token", post(token)) .route("/v1/task-grants/{id}/status", get(status)) .with_state(state.clone()); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let mut key = registry_platform_crypto::generate_private_jwk( registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, ) .unwrap(); key.alg = Some("RS256".into()); - let config = registry_platform_httputil::client::PrivateKeyJwtConfig::new( - format!("http://{address}/token").parse().unwrap(), - "breg-status", - key, - ) - .with_resource("urn:casework:test") - .with_scopes(["casework:grants:status"]); - let token = Arc::new(PrivateKeyJwt::new(config).unwrap()); - let client = TaskGrantStatusClient::new( - "casework".into(), - "https://casework.test".into(), - "urn:breg:test".into(), - format!("http://{address}").parse().unwrap(), - token, - None, - ) + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("status-key"); + let mut private = serde_json::to_value(&key).unwrap(); + for (name, value) in [ + ("d", &key.d), + ("p", &key.p), + ("q", &key.q), + ("dp", &key.dp), + ("dq", &key.dq), + ("qi", &key.qi), + ] { + if let Some(value) = value { + private[name] = Value::String(value.clone()); + } + } + std::fs::write(&path, serde_json::to_vec(&private).unwrap()).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let secrets = SecretResolver::new([SecretProvider::File], root.path()).unwrap(); + let config: TaskGrantStatusConfig = serde_json::from_value(serde_json::json!({ + "authority":"casework", + "sourceIssuer":"https://casework.test", + "baseUrl":issuer, + "tokenEndpoint":format!("http://{address}/token"), + "clientAssertionAudience":issuer, + "clientId":"breg-status", + "privateKeyRef":"secret:file/status-key", + "caseworkResource":"urn:casework:test" + })) .unwrap(); + let mut wrong = config.clone(); + wrong.client_assertion_audience = format!("http://{address}/token"); + let wrong = TaskGrantStatusRegistry::activate(&[wrong], "urn:breg:test", &secrets).unwrap(); + assert_eq!( + wrong.check(&binding).await, + Err(TaskGrantError::Unavailable) + ); + assert_eq!(state.rejected_token_calls.load(Ordering::SeqCst), 1); + assert_eq!(state.status_calls.load(Ordering::SeqCst), 0); + + let client = TaskGrantStatusRegistry::activate(&[config], "urn:breg:test", &secrets).unwrap(); client.check(&binding).await.unwrap(); *response.lock().unwrap() = serde_json::json!({"active":false}); assert_eq!(client.check(&binding).await, Err(TaskGrantError::Refused)); diff --git a/crates/registry-casework/src/task_grants/local_session_tests.rs b/crates/registry-casework/src/task_grants/local_session_tests.rs index 7a299451ba..a8d9adefd4 100644 --- a/crates/registry-casework/src/task_grants/local_session_tests.rs +++ b/crates/registry-casework/src/task_grants/local_session_tests.rs @@ -3,7 +3,8 @@ use super::native_resource as resource; use axum::http::{Method, StatusCode}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use registry_platform_httputil::{PrivateKeyJwt, PrivateKeyJwtConfig}; +use registry_breg::task_grant::{TaskGrantStatusConfig, TaskGrantStatusRegistry}; +use registry_platform_config::{SecretProvider, SecretResolver}; use serde_json::{json, Value}; use std::{ fs, @@ -278,36 +279,26 @@ async fn source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer() { let jwks: Value = serde_json::from_slice(&fs::read(session.root().join("secrets/issuer-jwks")).unwrap()) .unwrap(); - let status_key = registry_platform_crypto::PrivateJwk::parse( - &fs::read_to_string( - session - .root() - .join("credentials/status-client/assertion-key.jwk"), - ) - .unwrap(), - ) - .unwrap(); - let provider = PrivateKeyJwt::new( - PrivateKeyJwtConfig::new( - format!("{}/oauth2/token", session.issuer()) - .parse() - .unwrap(), - "status-client", - status_key, - ) - .with_audience(session.issuer()) - .with_resource(AUDIENCE) - .with_scopes(["casework:grants:status"]), + let secrets = SecretResolver::new( + [SecretProvider::File], + session.root().join("credentials/status-client"), ) .unwrap(); let checker = Arc::new( - registry_breg::task_grant::TaskGrantStatusClient::new( - "casework".into(), - AUTHORITY.into(), - AUDIENCE.into(), - session.url().parse().unwrap(), - Arc::new(provider), - None, + TaskGrantStatusRegistry::activate( + &[TaskGrantStatusConfig { + authority: "casework".into(), + source_issuer: AUTHORITY.into(), + base_url: session.url(), + token_endpoint: format!("{}/oauth2/token", session.issuer()), + client_assertion_audience: session.issuer(), + client_id: "status-client".into(), + private_key_ref: "secret:file/assertion-key.jwk".into(), + casework_resource: AUDIENCE.into(), + ca_bundle_ref: None, + }], + AUDIENCE, + &secrets, ) .unwrap(), ); diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index d20a8c2072..f0a2abda49 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -49,6 +49,7 @@ taskGrantStatus: sourceIssuer: https://casework.example.gov baseUrl: https://casework.example.gov tokenEndpoint: https://identity.example.gov/oauth2/token + clientAssertionAudience: https://identity.example.gov clientId: breg-task-status privateKeyRef: secret:file/breg-task-status-private-jwk caseworkResource: urn:casework:case-management @@ -58,9 +59,11 @@ Register this client for client credentials with the exact Casework resource and `casework:grants:status` scope. Register the same client in Casework's status client mapping for this BREG resource. BREG supplies its configured OIDC audience as that resource; neither a caller nor the grant chooses an outbound endpoint. -The private JWK must include its registered key identifier. `caBundleRef` can -refer to a private CA PEM bundle for both outbound connections. Production -endpoints use HTTPS; loopback HTTP is available for local development. +`clientAssertionAudience` is required and must exactly match the audience the +identity provider accepts for the signed client assertion. Stock ThunderID uses +its issuer URL. The private JWK must include its registered key identifier. +`caBundleRef` can refer to a private CA PEM bundle for both outbound connections. +Production endpoints use HTTPS; loopback HTTP is available for local development. Startup refuses a task profile without its configured authority/source-issuer mapping. A failed status request refuses the mutation. No positive status result diff --git a/products/breg/generated/runtime/runtime.schema.json b/products/breg/generated/runtime/runtime.schema.json index 1bb17f70f1..e6d9c8ff9a 100644 --- a/products/breg/generated/runtime/runtime.schema.json +++ b/products/breg/generated/runtime/runtime.schema.json @@ -1060,6 +1060,9 @@ "caseworkResource": { "type": "string" }, + "clientAssertionAudience": { + "type": "string" + }, "clientId": { "type": "string" }, @@ -1078,6 +1081,7 @@ "sourceIssuer", "baseUrl", "tokenEndpoint", + "clientAssertionAudience", "clientId", "privateKeyRef", "caseworkResource" diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 0c4cb5940d..dd3bd9cf6a 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -1982,7 +1982,7 @@ }, "artifact": { "path": "products/breg/generated/runtime/runtime.schema.json", - "sha256": "f5de662254f6aa4f7fe24b687575ea0a430f8e7ef31b430e448f4ba83869b05d", + "sha256": "238f6bb7091d1f9591110454e760cf57eacacc142e50b0b724b5804f62171273", "mediaType": "application/schema+json" } }, From be0cb349a79302510ed9f70aad9f291612b16919 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:31:46 +0700 Subject: [PATCH 105/120] fix(breg): require actor kind on access tokens Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/api/actions.rs | 3 - crates/registry-breg/src/api/context.rs | 17 ++++ crates/registry-breg/src/api/mod.rs | 3 - .../src/api/tests/immediate_action_tests.rs | 1 + crates/registry-breg/src/auth.rs | 13 ++- crates/registry-breg/src/fixtures.rs | 40 ++++++-- crates/registry-breg/tests/http_auth.rs | 99 +++++++++++++++---- .../tests/postgres_data_export.rs | 2 + .../tests/postgres_fixture_journeys.rs | 7 ++ .../postgres_immediate_action_examples.rs | 2 + .../tests/postgres_membership_access.rs | 2 +- .../tests/postgres_registry_extensibility.rs | 3 +- .../registry-breg/tests/postgres_startup.rs | 1 + .../tests/postgres_task_grants.rs | 2 +- .../tests/support/pilot_acceptance_harness.rs | 1 + .../tests/support/starter_policy.rs | 15 +-- crates/registry-bregctl/src/dev/config.rs | 7 +- crates/registry-bregctl/src/dev/tests.rs | 27 ++--- .../src/task_grants/native_exchange_tests.rs | 5 +- crates/registry-caseworkctl/src/source_add.rs | 27 +++-- crates/registry-evidence/src/auth.rs | 25 +++++ docs/site/src/content/docs/configure/breg.mdx | 11 +++ .../src/content/docs/configure/evidence.mdx | 10 ++ .../breg/scripts/test-adopter-workflow.sh | 1 + .../scripts/test-change-request-examples.sh | 1 + .../breg/scripts/test-historical-workflow.sh | 1 + .../breg/scripts/test-issuer-portability.py | 10 +- 27 files changed, 262 insertions(+), 74 deletions(-) diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 5d6d3c75c9..624994658f 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -144,9 +144,6 @@ fn authorize_action<'a>( { return None; } - if claims.actor_kind().is_some() && grant.actor_kind.is_none() { - return None; - } if let Some(expected) = grant.actor_kind { let actual = claims.actor_kind()?; if !matches!( diff --git a/crates/registry-breg/src/api/context.rs b/crates/registry-breg/src/api/context.rs index 968fa8ae4a..76df5d85f4 100644 --- a/crates/registry-breg/src/api/context.rs +++ b/crates/registry-breg/src/api/context.rs @@ -115,6 +115,23 @@ impl VerifiedRequestClaims { }) } + /// Construct authenticated authority material with an explicitly verified + /// actor kind. Production callers receive this value from bearer admission; + /// offline access previews may construct the same bounded context directly. + pub fn authenticated_with_actor_kind( + principal_claim: impl Into, + principal: impl Into, + scopes: BTreeSet, + purpose: Option, + direct_claims: BTreeMap, + actor_kind: ActorKind, + ) -> Result { + let mut claims = + Self::authenticated(principal_claim, principal, scopes, purpose, direct_claims)?; + claims.actor_kind = Some(actor_kind); + Ok(claims) + } + pub(crate) fn with_contextual_authority( mut self, actor_kind: Option, diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index b572e37774..d4076bc568 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -2772,9 +2772,6 @@ pub(crate) fn authorize_profile_claims( { return Err("purpose_missing_or_not_allowed"); } - if claims.actor_kind().is_some() && profile.actor_kind.is_none() { - return Err("actor_bound_profile_required"); - } if let Some(expected) = profile.actor_kind { let actual = claims.actor_kind().ok_or("actor_kind_missing")?; let matches = matches!( diff --git a/crates/registry-breg/src/api/tests/immediate_action_tests.rs b/crates/registry-breg/src/api/tests/immediate_action_tests.rs index 0163f94077..79812444ae 100644 --- a/crates/registry-breg/src/api/tests/immediate_action_tests.rs +++ b/crates/registry-breg/src/api/tests/immediate_action_tests.rs @@ -101,6 +101,7 @@ async fn signed_task_token_cannot_discover_or_invoke_an_ordinary_immediate_actio "registry_purpose": "case-management", "registry_grant_id": "00000000-0000-4000-8000-0000000000bb", "registry_grant_authority": "casework-v1", + "registry_approver": "h:synthetic-approver", "registry_grant_source_issuer": "https://casework.example", "registry_grant_client": "agent-client", "registry_grant_resource": audience, diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index 1ce8b7d69c..9b6bf80848 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -15,8 +15,8 @@ use axum::middleware::Next; use axum::response::Response; use registry_platform_authcommon::{parse_bearer_token, validate_compact_access_token}; use registry_platform_oidc::{ - actor_kind, grant_claims, is_access_token_typ_pair, Audience, ClaimError, ClaimNames, - JwksFetcher, OidcError, TokenVerifier, TokenVerifierConfig, + actor_kind, grant_claims, is_access_token_typ_pair, Audience, ClaimNames, JwksFetcher, + OidcError, TokenVerifier, TokenVerifierConfig, }; use serde_json::Value; use thiserror::Error; @@ -238,11 +238,10 @@ impl RegistryAuthenticator { }) .collect::, _>>()?; - let actor_kind = match actor_kind(&verified.claims, &self.contextual_claims) { - Ok(kind) => Some(kind), - Err(ClaimError::Missing(_)) => None, - Err(_) => return Err(AuthenticationError::InvalidClaims), - }; + let actor_kind = Some( + actor_kind(&verified.claims, &self.contextual_claims) + .map_err(|_| AuthenticationError::InvalidClaims)?, + ); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| AuthenticationError::InvalidClaims)? diff --git a/crates/registry-breg/src/fixtures.rs b/crates/registry-breg/src/fixtures.rs index 808b522578..75de10d8c7 100644 --- a/crates/registry-breg/src/fixtures.rs +++ b/crates/registry-breg/src/fixtures.rs @@ -4052,21 +4052,26 @@ fn verified_claims(step: &ValidatedStep) -> Result, _>>()?; - VerifiedRequestClaims::authenticated( + let actor_kind = match (step.profile.actor_kind, step.claims.actor_kind) { + (None, None) => registry_platform_oidc::ActorKind::Service, + (Some(expected), Some(actual)) if expected == actual => match actual { + crate::contract::ActorKindSource::Human => registry_platform_oidc::ActorKind::Human, + crate::contract::ActorKindSource::Agent => registry_platform_oidc::ActorKind::Agent, + crate::contract::ActorKindSource::Service => registry_platform_oidc::ActorKind::Service, + }, + _ => return Err(FixtureError::RequestConstructionRefused), + }; + VerifiedRequestClaims::authenticated_with_actor_kind( principal_claim, principal, step.claims.scopes.clone(), step.claims.purpose.clone(), direct_claims, + actor_kind, ) .and_then(|claims_context| { - let actor_kind = step.claims.actor_kind.map(|kind| match kind { - crate::contract::ActorKindSource::Human => registry_platform_oidc::ActorKind::Human, - crate::contract::ActorKindSource::Agent => registry_platform_oidc::ActorKind::Agent, - crate::contract::ActorKindSource::Service => registry_platform_oidc::ActorKind::Service, - }); claims_context.with_contextual_authority( - actor_kind, + Some(actor_kind), step.claims.requester_client.clone(), step.claims.actor_subject.clone(), None, @@ -6624,6 +6629,27 @@ mod tests { ); } + #[test] + fn fixture_claims_use_a_service_actor_only_for_actor_neutral_profiles() { + let fixture = package_fixture(DIGEST_A); + let suite = validate_fixture_journeys(JOURNEY_SOURCE, fixture.package.registry()) + .expect("strict suite validates"); + let step = &suite.journeys[0].steps[0]; + assert_eq!( + verified_claims(step) + .expect("actor-neutral fixture claims construct") + .actor_kind(), + Some(registry_platform_oidc::ActorKind::Service) + ); + + let mut actor_bound = step.clone(); + actor_bound.profile.actor_kind = Some(crate::contract::ActorKindSource::Agent); + assert_eq!( + verified_claims(&actor_bound).unwrap_err(), + FixtureError::RequestConstructionRefused + ); + } + const PROJECT_TEMPLATE: &[u8] = include_bytes!("../tests/fixtures/fixture-tooling/project.yaml"); const MODULE_SOURCE: &[u8] = include_bytes!("../tests/fixtures/fixture-tooling/module.yaml"); diff --git a/crates/registry-breg/tests/http_auth.rs b/crates/registry-breg/tests/http_auth.rs index 65b3e7a0cf..afd4906914 100644 --- a/crates/registry-breg/tests/http_auth.rs +++ b/crates/registry-breg/tests/http_auth.rs @@ -26,8 +26,8 @@ use registry_breg::{compile_project, parse_project_yaml, CompileProfile, Compile use registry_platform_crypto::PrivateJwk; use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ - access_token_typ_set, ClaimNames, JwksFetcher, JwksFetcherConfig, OidcError, TokenVerifier, - TokenVerifierConfig, + access_token_typ_set, ActorKind, ClaimNames, JwksFetcher, JwksFetcherConfig, OidcError, + TokenVerifier, TokenVerifierConfig, }; use registry_platform_testing::{ fixtures, jwks_from_private_jwk, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, @@ -131,6 +131,14 @@ entities: - {id: label, type: string, required: true, maxLength: 100, classification: public} - {id: tenant, type: string, required: true, maxLength: 100, classification: internal} accessProfiles: + - id: standing-human + principalClaim: sub + actorKind: human + requesterClients: [agent-client] + requiredScopes: [registry.read] + requiredPurposes: [record-review] + permissions: + - {entity: case, operations: [get], readableFields: [label], rowBoundaries: []} - id: standing-agent principalClaim: sub actorKind: agent @@ -348,6 +356,42 @@ async fn contextual_harness() -> Harness { } } +#[tokio::test] +async fn token_without_actor_kind_is_refused_before_profile_authorization() { + let harness = Harness::new().await; + let mut claims = valid_claims(); + claims + .as_object_mut() + .expect("fixture claims are an object") + .remove("registry_actor_kind"); + + assert_refused_without_record_call(&harness, &harness.idp.mint_token(claims)).await; +} + +#[tokio::test] +async fn agent_registered_client_without_actor_kind_cannot_use_a_human_profile() { + let harness = contextual_harness().await; + let token = harness.idp.mint_token(json!({ + "aud": AUDIENCE, + "sub": PRINCIPAL, + "azp": "agent-client", + "scope": "registry.read", + "registry_purpose": "record-review" + })); + + let response = harness + .send( + &format!("/v1/records/cases/{RECORD_ID}?accessProfile=standing-human"), + &[bearer(&token)], + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_json(response).await["code"], "authentication.refused"); + assert_eq!(harness.records.calls.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn task_grant_is_exactly_bound_and_cannot_fall_back_to_standing_authority() { let harness = contextual_harness().await; @@ -363,6 +407,7 @@ async fn task_grant_is_exactly_bound_and_cannot_fall_back_to_standing_authority( "registry_purpose": "record-review", "registry_grant_id": "00000000-0000-4000-8000-0000000000bb", "registry_grant_authority": "casework-v1", + "registry_approver": "h:synthetic-approver", "registry_grant_source_issuer": "https://casework.example", "registry_grant_client": "agent-client", "registry_grant_resource": AUDIENCE, @@ -842,6 +887,7 @@ async fn canonical_id_row_boundary_uses_the_compiled_uuid_claim_type() { let token = idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": PRINCIPAL, "record_id": RECORD_ID, })); @@ -852,6 +898,7 @@ async fn canonical_id_row_boundary_uses_the_compiled_uuid_claim_type() { let invalid_token = idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": PRINCIPAL, "record_id": "invalid-uuid-claim-never-rendered", })); @@ -1305,7 +1352,7 @@ async fn action_only_target_claims_are_mapped_without_crud_grants() { token_claims["allowed_owners"] = json!(["zone-a", "zone-b", "zone-a"]); let token = idp.mint_token(token_claims.clone()); let expected = |direct| { - VerifiedRequestClaims::authenticated( + VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", PRINCIPAL, BTreeSet::from([ @@ -1314,6 +1361,7 @@ async fn action_only_target_claims_are_mapped_without_crud_grants() { ]), Some(PURPOSE.to_owned()), direct, + ActorKind::Service, ) .unwrap() }; @@ -1637,6 +1685,7 @@ impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLogs { fn valid_claims() -> Value { json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": PRINCIPAL, "scope": "registry.read", "purpose": PURPOSE, @@ -1665,9 +1714,13 @@ async fn explicitly_selected_subject_can_also_supply_scalar_ownership_without_fa let registry = compile_project(&project, &[], CompileProfile::Authoring).unwrap(); let idp = MockIdp::start().await; let auth = authenticator(®istry, &idp, AuthorityClaimConfig::new("sub", None)).unwrap(); - let token = idp.mint_token(json!({"aud": AUDIENCE, "sub": RECORD_ID})); + let token = idp.mint_token(json!({ + "aud": AUDIENCE, + "sub": RECORD_ID, + "registry_actor_kind": "service" + })); let actual = auth.authenticate(&token).await.unwrap(); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "sub", RECORD_ID, BTreeSet::::new(), @@ -1676,13 +1729,14 @@ async fn explicitly_selected_subject_can_also_supply_scalar_ownership_without_fa "sub".to_owned(), VerifiedClaimValue::direct_string(RECORD_ID).unwrap(), )]), + ActorKind::Service, ) .unwrap(); assert_eq!(actual, expected); for payload in [ - json!({"aud": AUDIENCE, "registry_principal": RECORD_ID, "client_id": RECORD_ID, "azp": RECORD_ID}), - json!({"aud": AUDIENCE, "sub": "not-a-uuid"}), - json!({"aud": AUDIENCE, "sub": [RECORD_ID]}), + json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": RECORD_ID, "client_id": RECORD_ID, "azp": RECORD_ID}), + json!({"aud": AUDIENCE, "registry_actor_kind": "service", "sub": "not-a-uuid"}), + json!({"aud": AUDIENCE, "registry_actor_kind": "service", "sub": [RECORD_ID]}), ] { assert!(auth.authenticate(&idp.mint_token(payload)).await.is_err()); } @@ -1698,11 +1752,13 @@ async fn explicitly_selected_subject_can_also_supply_scalar_ownership_without_fa ) .unwrap(); assert!(auth - .authenticate(&idp.mint_token(json!({"aud": AUDIENCE, "registry_principal": RECORD_ID}))) + .authenticate(&idp.mint_token(json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": RECORD_ID}))) .await .is_ok()); assert!(auth - .authenticate(&idp.mint_token(json!({"aud": AUDIENCE, "sub": RECORD_ID}))) + .authenticate(&idp.mint_token( + json!({"aud": AUDIENCE, "registry_actor_kind": "service", "sub": RECORD_ID}) + )) .await .is_err()); @@ -1728,11 +1784,11 @@ async fn explicitly_selected_subject_can_also_supply_scalar_ownership_without_fa let date_principal = "2026-09-05"; let actual = auth .authenticate( - &idp.mint_token(json!({"aud": AUDIENCE, "registry_principal": date_principal})), + &idp.mint_token(json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": date_principal})), ) .await .unwrap(); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", date_principal, BTreeSet::::new(), @@ -1741,13 +1797,14 @@ async fn explicitly_selected_subject_can_also_supply_scalar_ownership_without_fa "registry_principal".to_owned(), VerifiedClaimValue::direct_string(date_principal).unwrap(), )]), + ActorKind::Service, ) .unwrap(); assert_eq!(actual, expected); for malformed in ["2026-02-30", "2026-09-05T00:00:00Z", "not-a-date"] { assert_eq!( auth.authenticate( - &idp.mint_token(json!({"aud": AUDIENCE, "registry_principal": malformed})) + &idp.mint_token(json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": malformed})) ) .await .unwrap_err(), @@ -1767,12 +1824,12 @@ async fn action_only_purpose_and_target_claims_are_discovered_from_signed_tokens .all(|entity| entity.access_profiles.is_empty())); let idp = MockIdp::start().await; let auth = authenticator(®istry, &idp, authority_claims()).unwrap(); - let base = json!({"aud": AUDIENCE, "registry_principal": PRINCIPAL, "scope": "case.rename", "purpose": "case-management", "regions": ["north"]}); + let base = json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": PRINCIPAL, "scope": "case.rename", "purpose": "case-management", "regions": ["north"]}); let actual = auth .authenticate(&idp.mint_token(base.clone())) .await .unwrap(); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", PRINCIPAL, BTreeSet::from(["case.rename".to_owned()]), @@ -1781,17 +1838,19 @@ async fn action_only_purpose_and_target_claims_are_discovered_from_signed_tokens "regions".to_owned(), VerifiedClaimValue::direct_string_set(["north"]).unwrap(), )]), + ActorKind::Service, ) .unwrap(); assert_eq!(actual, expected); let mut missing = base.clone(); missing.as_object_mut().unwrap().remove("regions"); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", PRINCIPAL, BTreeSet::from(["case.rename".to_owned()]), Some("case-management".to_owned()), BTreeMap::new(), + ActorKind::Service, ) .unwrap(); assert_eq!( @@ -1833,7 +1892,7 @@ async fn nested_workflow_and_lookup_claims_are_mapped_without_requiring_unrelate AuthorityClaimConfig::new("registry_principal", None), ) .unwrap(); - let base = json!({"aud": AUDIENCE, "registry_principal": PRINCIPAL, + let base = json!({"aud": AUDIENCE, "registry_actor_kind": "service", "registry_principal": PRINCIPAL, "apply_label": "A", "lookup_label": "L", "presence_label": "P", "review_labels": ["R"]}); let direct = BTreeMap::from([ ( @@ -1853,12 +1912,13 @@ async fn nested_workflow_and_lookup_claims_are_mapped_without_requiring_unrelate VerifiedClaimValue::direct_string_set(["R"]).unwrap(), ), ]); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", PRINCIPAL, BTreeSet::::new(), None, direct.clone(), + ActorKind::Service, ) .unwrap(); assert_eq!( @@ -1872,12 +1932,13 @@ async fn nested_workflow_and_lookup_claims_are_mapped_without_requiring_unrelate missing.as_object_mut().unwrap().remove(name); let mut expected_direct = direct.clone(); expected_direct.remove(name); - let expected = VerifiedRequestClaims::authenticated( + let expected = VerifiedRequestClaims::authenticated_with_actor_kind( "registry_principal", PRINCIPAL, BTreeSet::::new(), None, expected_direct, + ActorKind::Service, ) .unwrap(); assert_eq!( diff --git a/crates/registry-breg/tests/postgres_data_export.rs b/crates/registry-breg/tests/postgres_data_export.rs index 6687f72953..f7b872ba1a 100644 --- a/crates/registry-breg/tests/postgres_data_export.rs +++ b/crates/registry-breg/tests/postgres_data_export.rs @@ -75,12 +75,14 @@ async fn real_postgres_export_is_authenticated_projected_audited_and_resumable() let app = authenticated_app(&database, registry.clone(), identity.clone(), &idp); let token = idp.mint_token(json!({ "aud":AUDIENCE, + "registry_actor_kind":"service", "registry_principal":PRINCIPAL_CANARY, "purpose":"data-export", "jurisdictions":["north"] })); let wrong_purpose = idp.mint_token(json!({ "aud":AUDIENCE, + "registry_actor_kind":"service", "registry_principal":PRINCIPAL_CANARY, "purpose":"other-purpose", "jurisdictions":["north"] diff --git a/crates/registry-breg/tests/postgres_fixture_journeys.rs b/crates/registry-breg/tests/postgres_fixture_journeys.rs index 6bcaa68783..f1e1025441 100644 --- a/crates/registry-breg/tests/postgres_fixture_journeys.rs +++ b/crates/registry-breg/tests/postgres_fixture_journeys.rs @@ -1233,6 +1233,7 @@ fn household_credential_bindings( .join(" "); let token = idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": claims["actorKind"].as_str().unwrap_or("service"), "registry_principal": claims["principal"], "purpose": claims["purpose"], "scope": scope, @@ -1322,6 +1323,7 @@ fn overprivileged_credential_bindings( ) -> SchemaTestCredentialBindings { let mut overprivileged = json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "fixture-operator", "jurisdiction": "zone-a", "purpose": "case-management", @@ -1400,6 +1402,7 @@ fn credential_bindings_for_tokens( fn operator_token(idp: &MockIdp, purpose: bool) -> String { let mut claims = json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "fixture-operator", "jurisdiction": "zone-a", }); @@ -1412,6 +1415,7 @@ fn operator_token(idp: &MockIdp, purpose: bool) -> String { fn spatial_admin_token(idp: &MockIdp) -> String { idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "synthetic-service-site-admin", "registry_purpose": "service-site-administration", "scope": "service-sites:seed", @@ -1421,6 +1425,7 @@ fn spatial_admin_token(idp: &MockIdp) -> String { fn spatial_map_token(idp: &MockIdp) -> String { idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "synthetic-qgis-installation", "registry_purpose": "service-site-map", "scope": "service-sites:map.read", @@ -1431,6 +1436,7 @@ fn spatial_map_token(idp: &MockIdp) -> String { fn spatial_directory_token(idp: &MockIdp) -> String { idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "synthetic-directory-reader", "registry_purpose": "service-site-directory", "scope": "service-sites:directory.read", @@ -1440,6 +1446,7 @@ fn spatial_directory_token(idp: &MockIdp) -> String { fn spatial_site_token(idp: &MockIdp) -> String { idp.mint_token(json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "synthetic-site-reader", "registry_purpose": "service-site-map", "scope": "service-sites:site.read", diff --git a/crates/registry-breg/tests/postgres_immediate_action_examples.rs b/crates/registry-breg/tests/postgres_immediate_action_examples.rs index 06849c5aaa..b25fb73e7f 100644 --- a/crates/registry-breg/tests/postgres_immediate_action_examples.rs +++ b/crates/registry-breg/tests/postgres_immediate_action_examples.rs @@ -683,6 +683,7 @@ fn facility_registry_tokens( ); let broker = idp.mint_token(json!({ "aud":AUDIENCE, "registry_principal":"synthetic-transfer-broker", + "registry_actor_kind":"service", "purpose":"facility-administration", "scope":"registry:facility:transfer", "allowed_owners":["owner-a", "owner-b"], })); @@ -1073,6 +1074,7 @@ fn action_token( ) -> String { let mut claims = json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": principal, "purpose": purpose, }); diff --git a/crates/registry-breg/tests/postgres_membership_access.rs b/crates/registry-breg/tests/postgres_membership_access.rs index 1e3bf49ab4..a0cf7372f7 100644 --- a/crates/registry-breg/tests/postgres_membership_access.rs +++ b/crates/registry-breg/tests/postgres_membership_access.rs @@ -412,7 +412,7 @@ async fn real_postgres_membership_reads_recheck_live_membership_and_hide_process fn claims(idp: &MockIdp, principal: &str, scopes: &[&str]) -> String { idp.mint_token( - json!({"aud":"urn:breg:membership", "principal":principal, "scope":scopes.join(" ")}), + json!({"aud":"urn:breg:membership", "registry_actor_kind":"service", "principal":principal, "scope":scopes.join(" ")}), ) } struct AlwaysReady; diff --git a/crates/registry-breg/tests/postgres_registry_extensibility.rs b/crates/registry-breg/tests/postgres_registry_extensibility.rs index 23a2266324..150d04bf55 100644 --- a/crates/registry-breg/tests/postgres_registry_extensibility.rs +++ b/crates/registry-breg/tests/postgres_registry_extensibility.rs @@ -379,7 +379,8 @@ async fn facility_registration_transfer_and_events_use_governed_actions() { fn token(idp: &MockIdp, principal: &str, scope: &str, extra: Value) -> String { let mut claims = json!({ - "aud":AUDIENCE, "registry_principal":principal, "scope":scope, + "aud":AUDIENCE, "registry_actor_kind":"service", + "registry_principal":principal, "scope":scope, "purpose":"facility-administration" }); for (key, value) in extra.as_object().expect("extra claims object") { diff --git a/crates/registry-breg/tests/postgres_startup.rs b/crates/registry-breg/tests/postgres_startup.rs index dbf9b7dc02..8d122259dd 100644 --- a/crates/registry-breg/tests/postgres_startup.rs +++ b/crates/registry-breg/tests/postgres_startup.rs @@ -514,6 +514,7 @@ async fn live_old_server_drains_apply_and_exact_successor_restart_becomes_ready( .as_secs(); let token = idp.mint_token(json!({ "aud": "urn:breg:test", + "registry_actor_kind": "service", "principal": "recovery-operator", "iat": now, "nbf": now, diff --git a/crates/registry-breg/tests/postgres_task_grants.rs b/crates/registry-breg/tests/postgres_task_grants.rs index 18a25a9167..501d6cda3d 100644 --- a/crates/registry-breg/tests/postgres_task_grants.rs +++ b/crates/registry-breg/tests/postgres_task_grants.rs @@ -733,7 +733,7 @@ async fn task_http_to_postgres_preserves_original_authority_and_completed_receip .await; assert_eq!(changed.status, StatusCode::CONFLICT); assert_eq!(counts(&db).await, before); - let hidden=idp.mint_token(json!({"aud":AUDIENCE,"client_id":"human-client","sub":"applier","tenant_claim":"tenant-b","registry_purpose":"apply"})); + let hidden=idp.mint_token(json!({"aud":AUDIENCE,"client_id":"human-client","registry_actor_kind":"human","sub":"applier","tenant_claim":"tenant-b","registry_purpose":"apply"})); let refusal = perform(&app, &apply, &hidden, "apply").await; assert!( !refusal.status.is_success(), diff --git a/crates/registry-breg/tests/support/pilot_acceptance_harness.rs b/crates/registry-breg/tests/support/pilot_acceptance_harness.rs index e79de0ab7c..b68a1e6877 100644 --- a/crates/registry-breg/tests/support/pilot_acceptance_harness.rs +++ b/crates/registry-breg/tests/support/pilot_acceptance_harness.rs @@ -244,6 +244,7 @@ impl PilotHarness { ) -> String { let mut claims = json!({ "aud": AUDIENCE, + "registry_actor_kind": "service", "registry_principal": "pilot-operator", "purpose": purpose, }); diff --git a/crates/registry-breg/tests/support/starter_policy.rs b/crates/registry-breg/tests/support/starter_policy.rs index 34aedc90a0..beeed8561b 100644 --- a/crates/registry-breg/tests/support/starter_policy.rs +++ b/crates/registry-breg/tests/support/starter_policy.rs @@ -319,9 +319,9 @@ fn authored_credentials( { token_claims["client_id"] = json!(client); } - if !claims["actorKind"].is_null() { - token_claims["registry_actor_kind"] = claims["actorKind"].clone(); - } + token_claims["registry_actor_kind"] = claims["actorKind"] + .as_str() + .map_or_else(|| json!("service"), |kind| json!(kind)); let token = idp.mint_token(token_claims); bindings.push(SchemaTestCredentialBinding::bearer( journey["id"].as_str().unwrap(), @@ -369,13 +369,14 @@ impl StarterHttp<'_> { { token_claims["client_id"] = json!(client); } - if let Some(actor_kind) = authored_profile.actor_kind { - token_claims["registry_actor_kind"] = json!(match actor_kind { + token_claims["registry_actor_kind"] = json!(match authored_profile.actor_kind { + Some(actor_kind) => match actor_kind { ActorKindSource::Human => "human", ActorKindSource::Agent => "agent", ActorKindSource::Service => "service", - }); - } + }, + None => "service", + }); let token = self.idp.mint_token(token_claims); let separator = if path.contains('?') { '&' } else { '?' }; let mut request = Request::builder() diff --git a/crates/registry-bregctl/src/dev/config.rs b/crates/registry-bregctl/src/dev/config.rs index ba1451ee67..ebec8f21ec 100644 --- a/crates/registry-bregctl/src/dev/config.rs +++ b/crates/registry-bregctl/src/dev/config.rs @@ -343,7 +343,7 @@ pub(super) fn issuer_description( .clients .iter() .map(|client| { - let claims = client + let mut claims = client .claims .iter() .map(|(name, value)| { @@ -353,6 +353,11 @@ pub(super) fn issuer_description( Ok((name.clone(), value.to_owned())) }) .collect::>>()?; + // An omitted local marker describes the ordinary machine client. + // Explicit human and agent teaching identities retain their kind. + claims + .entry("registry_actor_kind".to_owned()) + .or_insert_with(|| "service".to_owned()); let directory = root.join("credentials").join(&client.id); let public: Value = serde_json::from_slice(&private::read(&directory.join("public.jwk"), 4096)?)?; diff --git a/crates/registry-bregctl/src/dev/tests.rs b/crates/registry-bregctl/src/dev/tests.rs index b593c72896..4a5e31f707 100644 --- a/crates/registry-bregctl/src/dev/tests.rs +++ b/crates/registry-bregctl/src/dev/tests.rs @@ -231,7 +231,7 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { allow_breg_access: true, allow_human_fixture: false, scopes: vec!["registry:generic:review".into()], - claims: BTreeMap::new(), + claims: BTreeMap::from([("registry_actor_kind".into(), json!("agent"))]), test_bindings: Vec::new(), client_id_file: None, assertion_key_file: None, @@ -250,18 +250,19 @@ fn profile_free_clients_need_explicit_breg_access_to_authenticate() { initialize(&state.root(), &state, &clients, &files).unwrap(); let root = state.root(); let issuer = config::issuer_description(&state, &clients, &root).unwrap(); - assert!(issuer - .machine_clients - .iter() - .any(|client| client.client_id == "guest")); - assert!(issuer - .machine_clients - .iter() - .any(|client| client.client_id == "casework-reviewer")); - assert!(issuer - .machine_clients - .iter() - .any(|client| client.client_id == "casework-administrator")); + for (id, actor_kind) in [ + ("operator", "service"), + ("guest", "service"), + ("casework-reviewer", "agent"), + ("casework-administrator", "human"), + ] { + let client = issuer + .machine_clients + .iter() + .find(|client| client.client_id == id) + .expect("each authored client is registered"); + assert_eq!(client.attributes["registry_actor_kind"], actor_kind); + } let runtime: Value = serde_norway::from_slice( &private::read(&root.join("runtime-test.yaml"), MAX_BYTES).unwrap(), ) diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 732c4226dd..77b93c8293 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -458,8 +458,8 @@ fn start_issuer( ], ) .unwrap(); - let mut seed_client = client("seed-client", seed, None, &["records:get"]); - seed_client.claims = BTreeMap::from([ + let mut seed_client = client("seed-client", seed, Some("service"), &["records:get"]); + seed_client.claims.extend([ ("tenant_claim".into(), "tenant-a".into()), ("registry_purpose".into(), "maintain".into()), ]); @@ -1112,6 +1112,7 @@ async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_re .await .unwrap(), ); + assert_eq!(payload(&seed)["registry_actor_kind"], "service"); let old = resource::create( &app, "/v1/records/sites?accessProfile=steward", diff --git a/crates/registry-caseworkctl/src/source_add.rs b/crates/registry-caseworkctl/src/source_add.rs index 7a15555254..c0ca07d0b9 100644 --- a/crates/registry-caseworkctl/src/source_add.rs +++ b/crates/registry-caseworkctl/src/source_add.rs @@ -25,6 +25,8 @@ const READER_CLIENT_ID: &str = "casework-reader"; const READER_PRINCIPAL_CLAIM: &str = "registry_principal"; const READER_SCOPE: &str = "casework:source-reader"; const READER_PURPOSE: &str = "casework-sync"; +const ACTOR_KIND_CLAIM: &str = "registry_actor_kind"; +const SERVICE_ACTOR_KIND: &str = "service"; /// The issuer claim a local BReg client's access token carries its purpose under. const PURPOSE_CLAIM: &str = "registry_purpose"; @@ -647,15 +649,19 @@ fn leading_spaces(line: &str) -> usize { line.bytes().take_while(|byte| *byte == b' ').count() } -/// The local BReg client exercising the casework-reader access profile, with -/// the same id, scope, purpose, and principal claim `candidate_fragments` +/// The local BReg service client exercising the casework-reader access profile, +/// with the same id, scope, purpose, and principal claim `candidate_fragments` /// authors the profile itself with. fn reader_dev_client() -> Value { json!({ "id": READER_CLIENT_ID, "accessProfiles": [READER_CLIENT_ID], "scopes": [READER_SCOPE], - "claims": {READER_PRINCIPAL_CLAIM: READER_CLIENT_ID, PURPOSE_CLAIM: READER_PURPOSE}, + "claims": { + ACTOR_KIND_CLAIM: SERVICE_ACTOR_KIND, + READER_PRINCIPAL_CLAIM: READER_CLIENT_ID, + PURPOSE_CLAIM: READER_PURPOSE, + }, }) } @@ -781,8 +787,9 @@ fn has_nonempty_row_boundaries(value: &Value) -> bool { } } -/// The BReg dev client bound to one Casework dev client: same id and scopes -/// and claims, no access profile of its own. A staff or supervisor client +/// The BReg dev client bound to one Casework dev client: same id, scopes, and +/// claims, plus an explicit service actor kind for the requester. It has no +/// access profile of its own. A staff or supervisor client /// additionally carries the selected request's reviewer scopes, maps its /// Casework principal into the BReg reviewer claim, and carries any required /// purpose claim. Only those reviewer roles explicitly opt in to BReg's @@ -809,6 +816,9 @@ fn human_dev_client( .flatten() .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned()))) .collect(); + if role == "requester" { + claims.insert(ACTOR_KIND_CLAIM.to_owned(), SERVICE_ACTOR_KIND.to_owned()); + } let allow_breg_access = matches!(role, "staff" | "supervisor"); if allow_breg_access { let authority = authority.context( @@ -1549,7 +1559,7 @@ mod tests { assert_eq!(reader["scopes"], json!(["casework:source-reader"])); assert_eq!( reader["claims"], - json!({"registry_principal":"casework-reader","registry_purpose":"casework-sync"}) + json!({"registry_actor_kind":"service","registry_principal":"casework-reader","registry_purpose":"casework-sync"}) ); let administrator = clients.iter().find(|c| c["id"] == "administrator").unwrap(); @@ -1722,7 +1732,10 @@ mod tests { assert_eq!(requester["accessProfiles"], json!([])); assert!(requester.get("allowBregAccess").is_none()); assert_eq!(requester["scopes"], json!(["casework:request"])); - assert_eq!(requester["claims"], json!({})); + assert_eq!( + requester["claims"], + json!({"registry_actor_kind":"service"}) + ); } #[test] diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index a8ac58688e..d79d9b6e42 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -1152,6 +1152,31 @@ mod tests { assert_eq!(context.grant(), Err(TaskGrantError::PrincipalMismatch)); } + #[test] + fn token_without_actor_kind_is_refused_during_context_extraction() { + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::from_value(serde_json::json!({ + "iss": "https://issuer.invalid", + "aud": "evidence-resource", + "sub": "service-principal", + "exp": now + 300, + "registry_purpose": "standing-service", + "evidence_tags": ["service"], + "evidence_audience": "https://relying-party.invalid" + })) + .expect("claims parse"); + + let error = context_extraction_authenticator("sub") + .extract_context(VerifiedToken { + claims, + matched_client: Some("client_id:evidence-agent".to_owned()), + scopes: Vec::new(), + }) + .expect_err("a token without registry_actor_kind is refused"); + + assert!(matches!(error, AuthenticationError::Context)); + } + #[test] fn purpose_only_service_token_is_not_a_task_grant() { let now = chrono::Utc::now().timestamp(); diff --git a/docs/site/src/content/docs/configure/breg.mdx b/docs/site/src/content/docs/configure/breg.mdx index 6cf235f655..30800f7295 100644 --- a/docs/site/src/content/docs/configure/breg.mdx +++ b/docs/site/src/content/docs/configure/breg.mdx @@ -126,6 +126,17 @@ and a snippet copied from a Relay page does not compile here. | `accessProfiles` | Every profile a token can select. | | `vocabularies` | Closed code lists that `vocabulary-code` fields reference. | +Configure the OpenID Connect (OIDC) issuer to include `registry_actor_kind` on every BReg access +token. The value must be exactly `human`, `agent`, or `service`; a missing or malformed value causes +authentication to fail before BReg selects an access profile. An `actorKind` on an access profile +further restricts that profile to the matching token kind and must be paired with +`requesterClients`. Omit `actorKind` only when the profile deliberately accepts all three declared +actor kinds. + +{/* Evidence: crates/registry-breg/src/auth.rs, RegistryAuthenticator::authenticate(); + crates/registry-breg/src/api/mod.rs, authorize_profile_claims(); + crates/registry-breg/src/compiler.rs. */} + Identifiers follow a closed grammar: a lowercase ASCII letter first, then lowercase letters, digits, hyphens, or underscores, at most 64 bytes. `check` refuses anything else with `identifier.invalid`, so an event id such as `record.status.changed` fails where `record-status-changed-v1` passes. The project convention is kebab-case for entity ids, field ids, profile ids, and vocabulary ids. diff --git a/docs/site/src/content/docs/configure/evidence.mdx b/docs/site/src/content/docs/configure/evidence.mdx index 2af3614966..34ac2897cc 100644 --- a/docs/site/src/content/docs/configure/evidence.mdx +++ b/docs/site/src/content/docs/configure/evidence.mdx @@ -138,6 +138,16 @@ atomic revision (`products/evidence/contracts/bundle.schema.yaml`). Its top-leve | `authorityProfiles` | Named requester-tag gates: which grants (requirement, purpose, audience, subject roles, selector profiles, value origins) a caller carrying those tags holds. | | `requirements` | The assertion definitions themselves: id, closed acquisition, purposes, subject roles, reference frameworks, evidence type, validity, derivation script and parameters, output concepts, fixtures, and disclosure-guard family. | +Configure the OpenID Connect (OIDC) issuer to include `registry_actor_kind` on every Evidence +access token. The claim name defaults to `registry_actor_kind` and can be changed with +`authentication.claims.actorKind`. Its value must be exactly `human`, `agent`, or `service`; +a missing or malformed value causes authentication to fail before Evidence evaluates authority or +accesses a source. + +{/* Evidence: crates/registry-evidence/src/auth.rs, Authenticator::extract_context(); + crates/registry-platform-oidc/src/authorization_claims.rs, actor_kind(); + products/evidence/contracts/bundle.schema.yaml. */} + Everything under `bundle/` is mounted read-only and covered by one revision digest computed over the exact bundle bytes; there is no partial edit, hot reload, or fallback (`bundle.schema.yaml`, `atomic_revision`). Allowed bundle directories are `adapters/`, diff --git a/products/breg/scripts/test-adopter-workflow.sh b/products/breg/scripts/test-adopter-workflow.sh index 4a2b2c235a..0bfbc81b94 100755 --- a/products/breg/scripts/test-adopter-workflow.sh +++ b/products/breg/scripts/test-adopter-workflow.sh @@ -224,6 +224,7 @@ claims = { "iat": now, "iss": "https://issuer.example/adopter", "jti": f"adopter-{now}-{sys.argv[2]}-{sys.argv[3] or 'none'}", + "registry_actor_kind": "service", "registry_principal": sys.argv[2], "sub": sys.argv[2], } diff --git a/products/breg/scripts/test-change-request-examples.sh b/products/breg/scripts/test-change-request-examples.sh index cdeb3b7640..dab43d7569 100755 --- a/products/breg/scripts/test-change-request-examples.sh +++ b/products/breg/scripts/test-change-request-examples.sh @@ -372,6 +372,7 @@ claims = { "iat": now, "iss": "https://issuer.example/change-request-example", "jti": f"change-request-example-{now}-{sys.argv[2]}", + "registry_actor_kind": "service", "registry_principal": sys.argv[2], "sub": sys.argv[2], } diff --git a/products/breg/scripts/test-historical-workflow.sh b/products/breg/scripts/test-historical-workflow.sh index dd70abf836..0b0e190975 100755 --- a/products/breg/scripts/test-historical-workflow.sh +++ b/products/breg/scripts/test-historical-workflow.sh @@ -255,6 +255,7 @@ claims = { "iat": now, "iss": "https://issuer.example/history", "jti": f"history-{now}-{sys.argv[2]}-{sys.argv[3] or 'none'}", + "registry_actor_kind": "service", "registry_principal": sys.argv[2], "sub": sys.argv[2], } diff --git a/products/breg/scripts/test-issuer-portability.py b/products/breg/scripts/test-issuer-portability.py index a5b41c146f..5a0ab6f80a 100644 --- a/products/breg/scripts/test-issuer-portability.py +++ b/products/breg/scripts/test-issuer-portability.py @@ -122,6 +122,9 @@ def realm(client_secret: str, password: str, callback: str, audience: str) -> di mapper("stable principal", "oidc-usermodel-attribute-mapper", { "user.attribute": "registry_principal", "claim.name": "registry_principal", "jsonType.label": "String"}), + mapper("actor kind", "oidc-usermodel-attribute-mapper", { + "user.attribute": "registry_actor_kind", "claim.name": "registry_actor_kind", + "jsonType.label": "String"}), mapper("district assignments", "oidc-usermodel-attribute-mapper", { "user.attribute": "districts", "claim.name": "districts", "jsonType.label": "String"}), @@ -135,7 +138,8 @@ def realm(client_secret: str, password: str, callback: str, audience: str) -> di "defaultClientScopes": [], "optionalClientScopes": ["registry:read"], "protocolMappers": authority, "directAccessGrantsEnabled": False, "fullScopeAllowed": False} - assignments = {"registry_principal": [PRINCIPAL], "districts": ["district-a"]} + assignments = {"registry_actor_kind": ["service"], + "registry_principal": [PRINCIPAL], "districts": ["district-a"]} return { "realm": "breg-issuer-journey", "enabled": True, "sslRequired": "none", "accessTokenLifespan": 300, @@ -154,7 +158,8 @@ def realm(client_secret: str, password: str, callback: str, audience: str) -> di {"username": "synthetic-clerk", "enabled": True, "email": "clerk@example.test", "emailVerified": True, "firstName": "Synthetic", "lastName": "Clerk", - "attributes": {**assignments, "registry_principal": [HUMAN_PRINCIPAL]}, + "attributes": {**assignments, "registry_actor_kind": ["human"], + "registry_principal": [HUMAN_PRINCIPAL]}, "credentials": [{"type": "password", "value": password, "temporary": False}]}, ], } @@ -244,6 +249,7 @@ def journey(root: Path, bregctl: str, breg: str, docker: str, accessProfiles: [clerk] scopes: [registry:read] claims: + registry_actor_kind: service registry_principal: urn:institution:service-clerk registry_purpose: registry-administration districts: district-a From c8f1192eb4647dc1778885cf88e203d4de7ae95f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:38:59 +0700 Subject: [PATCH 106/120] fix(docs): remove retired Mint tutorial steps Signed-off-by: Jeremi Joslin --- docs/site/scripts/check-tutorial.sh | 19 +++++++++---------- docs/site/scripts/check-tutorial.test.mjs | 9 +++++++++ .../docs/reference/evidence-configuration.mdx | 7 +++---- .../content/docs/reference/evidencectl.mdx | 1 - .../assert-a-role-bound-relationship.mdx | 2 +- .../build-and-deploy-evidence-project.mdx | 11 ++++------- ...evidence-candidate-with-docker-compose.mdx | 8 -------- ...issue-immunization-evidence-from-dhis2.mdx | 6 +++--- .../refuse-unsafe-evidence-requests.mdx | 2 +- .../request-evidence-as-sd-jwt-vc.mdx | 2 +- ...rify-a-registered-parent-with-opencrvs.mdx | 6 +++--- products/breg/EVENTS-AND-WEBHOOKS.md | 2 +- 12 files changed, 35 insertions(+), 40 deletions(-) diff --git a/docs/site/scripts/check-tutorial.sh b/docs/site/scripts/check-tutorial.sh index bbfb4f30be..ad57c32c22 100755 --- a/docs/site/scripts/check-tutorial.sh +++ b/docs/site/scripts/check-tutorial.sh @@ -23,8 +23,8 @@ # tutorial itself tells readers to clone `main`. # REGISTRY_STACK_SOURCE_DIR # clean Registry Stack checkout the lab builds the -# Evidence and Mint images from, a documented tutorial -# prerequisite until a release publishes them. +# Evidence image from, a documented tutorial +# prerequisite until a release publishes it. # Default: this repository. # # Exit codes: @@ -50,9 +50,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TUTORIAL="${SOLMARA_TUTORIAL_PAGE:-$REPO_ROOT/src/content/docs/tutorials/first-run-with-solmara-lab.mdx}" EXPECTED_DEMO_ARTIFACTS=3 -# Every service the tutorial names or gives a host port. The topology holds far -# more; EXPECTED_RUNNING_TOTAL below covers the rest as a count, because the -# page states one. +# Registry Stack services that this historical gate still owns by name. The +# topology holds far more; EXPECTED_RUNNING_TOTAL below covers the rest as a +# count, because the page states one. EXPECTED_SERVICES=( cra-civil-relay nia-population-relay @@ -67,7 +67,6 @@ EXPECTED_SERVICES=( scenario-runner postgres evidence-gateway - mint cra-records-relay cra-records-workload-agent evidence @@ -199,10 +198,10 @@ if [[ ! -f "$LAB_DIR/compose.evidence.yaml" ]]; then exit 1 fi -# No Registry Stack release ships the Evidence or Mint binaries, so the lab -# builds both images from a checkout the operator names, and refuses a dirty -# one. This is a documented tutorial prerequisite until a release publishes -# them; default it to the repository this script lives in. +# No Registry Stack release ships the Evidence binary, so the lab builds its +# Evidence image from a checkout the operator names and refuses a dirty one. +# This is a documented tutorial prerequisite until a release publishes it; +# default it to the repository this script lives in. REGISTRY_STACK_SOURCE_DIR="${REGISTRY_STACK_SOURCE_DIR:-$(cd "$REPO_ROOT/../.." && pwd)}" export REGISTRY_STACK_SOURCE_DIR if [[ -n "$(git -C "$REGISTRY_STACK_SOURCE_DIR" status --porcelain 2>&1)" ]]; then diff --git a/docs/site/scripts/check-tutorial.test.mjs b/docs/site/scripts/check-tutorial.test.mjs index 232bb915e8..03fa32b5a5 100644 --- a/docs/site/scripts/check-tutorial.test.mjs +++ b/docs/site/scripts/check-tutorial.test.mjs @@ -62,3 +62,12 @@ test('the gate pins no extraction counts', async () => { assert.match(source, /EXPECTED_SERVICES=\(/u); assert.match(source, /EXPECTED_DEMO_ARTIFACTS=\d+/u); }); + +test('the historical gate no longer requires Mint from the Registry Stack checkout', async () => { + const source = await readFile(gatePath, 'utf8'); + const expectedServices = source.match(/EXPECTED_SERVICES=\([\s\S]*?\n\)/u)?.[0]; + + assert.ok(expectedServices, 'the expected service list must exist'); + assert.doesNotMatch(expectedServices, /^\s*mint\s*$/mu); + assert.doesNotMatch(source, /Evidence and Mint images|Evidence or Mint binaries|builds both images/u); +}); diff --git a/docs/site/src/content/docs/reference/evidence-configuration.mdx b/docs/site/src/content/docs/reference/evidence-configuration.mdx index ca4662c9cb..e21077254d 100644 --- a/docs/site/src/content/docs/reference/evidence-configuration.mdx +++ b/docs/site/src/content/docs/reference/evidence-configuration.mdx @@ -121,10 +121,9 @@ complete authoring and deployment path. A key path that this page lists is not a deployment that runs. `evidence check` is the authoritative runtime acceptance gate. `evidencectl artifact inspect ` reports artifact custody, and -can add a mechanical Mint compatibility comparison. `evidencectl doctor --runtime-config -` performs the live startup dependency preflight without opening the public -listener or sending an Evidence request. `evidencectl test` invokes the real Evidence checks -before evaluating fixtures offline. +`evidencectl doctor --runtime-config ` performs the live startup dependency +preflight without opening the public listener or sending an Evidence request. `evidencectl test` +invokes the real Evidence checks before evaluating fixtures offline. {/* Evidence: crates/registry-evidencectl/src/runtime.rs, DoctorArgs and run(); crates/registry-evidencectl/src/doctor.rs; crates/registry-evidencectl/src/fixtures.rs. */} diff --git a/docs/site/src/content/docs/reference/evidencectl.mdx b/docs/site/src/content/docs/reference/evidencectl.mdx index 4618546d39..33d5f925ea 100644 --- a/docs/site/src/content/docs/reference/evidencectl.mdx +++ b/docs/site/src/content/docs/reference/evidencectl.mdx @@ -38,7 +38,6 @@ The canonical adopter commands do not change the numeric Evidence Gateway Versio | `evidencectl test --explain` | The same, and asks each evaluation to explain itself | Relays each fixture's stage trace beside its step, or as that fixture's `trace` field under `--format json` | Print a response, fact, derived, or selector value, or explain a served request | | `evidencectl doctor --runtime-config ` | Runtime file on its target host | Runs the runtime-owned startup dependency preflight without opening the public listener | Send an Evidence request or establish that a fixture passed | | `evidencectl artifact inspect ` | Completed candidate | Inspects artifact custody without contacting dependencies | Establish live dependency readiness | -| `evidencectl artifact inspect --mint-config ` | Completed candidate and separately authored Mint configuration | Adds a read-only mechanical protocol compatibility comparison | Register callers, copy Mint files, infer authority, or mint a token | `package` is create-only. It rejects an existing output directory, unauthenticated or non-HTTPS production HTTP sources, source transports with no stated production conditions, missing governance diff --git a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx index 68aebae3ad..1bdc6b4a16 100644 --- a/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx +++ b/docs/site/src/content/docs/tutorials/assert-a-role-bound-relationship.mdx @@ -174,7 +174,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` ## Bind both subjects to the request diff --git a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx index 440660a311..4819256839 100644 --- a/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx +++ b/docs/site/src/content/docs/tutorials/build-and-deploy-evidence-project.mdx @@ -97,21 +97,18 @@ shared/ environments/ local/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} staging/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} transit/{proxy-configs/,policies/} production/ evidence/{governance.yaml,runtime.yaml,public-keys/} - mint/{mint.yaml,clients/,public-keys/} transit/{proxy-configs/,policies/} ``` -Omit the Mint directories when the deployment uses another OIDC issuer. Every environment target -is complete. Do not use overlays, environment branches, symlinks, or runtime substitutions. Git -contains public keys and nonsecret provider configuration, but never private JWKs, HMAC keys, -provider tokens, auto-auth credentials, access tokens, live responses, or real identifiers. +Every environment target is complete. Do not use overlays, environment branches, symlinks, or +runtime substitutions. Git contains public keys and nonsecret provider configuration, but never +private JWKs, HMAC keys, provider tokens, auto-auth credentials, access tokens, live responses, or +real identifiers. `governance.yaml` provides bundle-owned production values. It contains version `1`, `assuranceProfile: production`, service and issuer, authentication, audit, subject binding, rate diff --git a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx index 3f72b3e433..57db988992 100644 --- a/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx +++ b/docs/site/src/content/docs/tutorials/integrate-evidence-candidate-with-docker-compose.mdx @@ -170,14 +170,6 @@ it covers one requirement's own configuration and artifacts. Signed assertions c the requirement they answer as `configurationRevision`, never the runtime or bundle revision. No revision contains secret values or audit contents. -## Add optional Mint - -When the same application uses Mint, run Mint as a separate private service. Mint retains its public -HTTPS issuer and JWKS URI, while internal routing or split DNS resolves that identity. Evidence Gateway must -continue to use the public HTTPS issuer and JWKS URI, not an internal plain-HTTP service name. -Strict Mint uses its own proxy, Unix-socket directory, provider identity, policy, and Transit key. -Do not share the Evidence Gateway proxy or socket with Mint. - ## Stop without deleting the audit history `docker compose down` removes the containers and the network it created. It leaves named volumes diff --git a/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx b/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx index 5c67087bea..e63805502e 100644 --- a/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx +++ b/docs/site/src/content/docs/tutorials/issue-immunization-evidence-from-dhis2.mdx @@ -650,8 +650,8 @@ Evidence Gateway validates every value against its declared form before signing. ## Start the project -Compile the editable source and question into one immutable local generation, then start Registry -Mint and Evidence Gateway: +Compile the editable source and question into one immutable local generation, then start Evidence +Gateway and its local issuer: ```sh evidencectl dev start . @@ -659,7 +659,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` This command validates the source schema, collection bounds, extractor, fact schema, question, diff --git a/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx b/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx index 88fdccfeab..42d2366214 100644 --- a/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx +++ b/docs/site/src/content/docs/tutorials/refuse-unsafe-evidence-requests.mdx @@ -55,7 +55,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` ## Prepare one authorized request diff --git a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx index 242d1223ca..c78ba04fb5 100644 --- a/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx +++ b/docs/site/src/content/docs/tutorials/request-evidence-as-sd-jwt-vc.mdx @@ -82,7 +82,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` Prepare a request that records SD-JWT VC as the expected response format before any response diff --git a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx index a83e912dad..3c24c37467 100644 --- a/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx +++ b/docs/site/src/content/docs/tutorials/verify-a-registered-parent-with-opencrvs.mdx @@ -599,8 +599,8 @@ derivation cannot add names, dates, addresses, or any other birth-registration f ## Start the project -Compile the editable artifacts into a private local generation and start Evidence Gateway with Registry -Mint: +Compile the editable artifacts into a private local generation and start Evidence Gateway with its +local issuer: ```sh evidencectl dev start . @@ -608,7 +608,7 @@ evidencectl dev start . ```text Evidence ready at http://127.0.0.1:8080 -Mint ready at http://127.0.0.1:8081 +Issuer ready at http://127.0.0.1:8081 ``` `dev` runs the real Evidence Gateway configuration check before either service becomes ready. Unresolved diff --git a/products/breg/EVENTS-AND-WEBHOOKS.md b/products/breg/EVENTS-AND-WEBHOOKS.md index ffe68d0fb3..0e2314576e 100644 --- a/products/breg/EVENTS-AND-WEBHOOKS.md +++ b/products/breg/EVENTS-AND-WEBHOOKS.md @@ -213,7 +213,7 @@ The first complete journey must be possible without reading Rust code: status. - `bregctl webhook replay` replays one eligible dead letter using its event id, delivery id, and expected generation. -- `products/breg/demo/run.sh --webhook` starts Mint, PostgreSQL, +- `products/breg/demo/run.sh --webhook` starts ThunderID, PostgreSQL, Base Registry Engine, and a local HMAC-verifying receiver. Its smoke journey demonstrates automatic retry, dead-letter inspection, operator replay, and eventual authenticated success without printing the token or key. From 2c790d07f23299b96d1b0398e4ffa4c0f83388ae Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:39:55 +0700 Subject: [PATCH 107/120] fix(auth): attribute approved task grants Signed-off-by: Jeremi Joslin --- crates/registry-casework/src/runtime.rs | 14 ++++--- crates/registry-casework/src/task_grants.rs | 33 ++++++++++++++-- .../src/task_grants/http_tests.rs | 1 + .../src/task_grants/native_exchange_tests.rs | 1 + .../tests/against_a_real_deployment.rs | 5 +++ .../registry-evidence/benches/audit_bench.rs | 1 + crates/registry-evidence/src/audit.rs | 27 +++++++++++++ crates/registry-evidence/src/auth.rs | 1 + crates/registry-evidence/src/main.rs | 1 + crates/registry-evidence/src/observability.rs | 1 + crates/registry-evidence/src/runtime.rs | 38 +++++++++++++++++- crates/registry-evidence/src/runtime_tests.rs | 6 +++ crates/registry-evidence/src/selector.rs | 4 ++ .../tests/selector_conformance.rs | 2 + crates/registry-platform-audit/README.md | 5 ++- .../src/authorization.rs | 30 ++++++++++++++ crates/registry-platform-oidc/README.md | 6 +-- .../src/authorization_claims.rs | 39 ++++++++++--------- .../contracts/security-invariant-matrix.yaml | 2 +- .../contracts/security-test-traceability.yaml | 2 +- .../contracts/audit-event.schema.yaml | 7 ++-- .../request-batch-audit-event.schema.yaml | 4 +- .../contracts/security-invariant-matrix.yaml | 6 +-- .../contracts/security-test-traceability.yaml | 1 + 24 files changed, 193 insertions(+), 44 deletions(-) diff --git a/crates/registry-casework/src/runtime.rs b/crates/registry-casework/src/runtime.rs index 9df902b990..6dc760b831 100644 --- a/crates/registry-casework/src/runtime.rs +++ b/crates/registry-casework/src/runtime.rs @@ -103,19 +103,21 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> keys, config.authentication.oidc.human_identity.clone(), )); + let audit_secret = resolve_audit_secret(&secrets, &config.audit.hash_key_ref)?; + let audit_profile = AuditProfile::production_from_secret_bytes(zeroize::Zeroizing::new( + audit_secret.expose_secret().to_vec(), + )) + .map_err(|_| RuntimeError::Audit)?; let task_authority = config .task_authority .as_ref() - .map(|authority| crate::task_grants::TaskAuthority::load(authority, &secrets)) + .map(|authority| { + crate::task_grants::TaskAuthority::load(authority, &secrets, audit_profile.key_hasher()) + }) .transpose()?; let service = CaseworkService::new(store.clone(), project.clone(), adapters)? .with_task_authority(task_authority); - let audit_secret = resolve_audit_secret(&secrets, &config.audit.hash_key_ref)?; - let audit_profile = AuditProfile::production_from_secret_bytes(zeroize::Zeroizing::new( - audit_secret.expose_secret().to_vec(), - )) - .map_err(|_| RuntimeError::Audit)?; let audit_sink = Arc::new( JsonlFileSink::new_single_writer(&config.audit.path).map_err(|_| RuntimeError::Audit)?, ); diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 9f496d91af..618cd66f48 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -345,11 +345,13 @@ async fn task_event( pub(crate) struct TaskAuthority { config: crate::TaskAuthorityConfig, key: registry_platform_crypto::PrivateJwk, + identifiers: registry_platform_audit::AuditKeyHasher, } impl TaskAuthority { pub(crate) fn load( config: &crate::TaskAuthorityConfig, secrets: ®istry_platform_config::SecretResolver, + identifiers: registry_platform_audit::AuditKeyHasher, ) -> Result { let secret = secrets .resolve(&config.signing_key_ref) @@ -366,11 +368,21 @@ impl TaskAuthority { Ok(Self { config: config.clone(), key, + identifiers, }) } pub(crate) fn jwks(&self) -> Result { Ok(json!({"keys":[self.key.public()]})) } + fn approver_pseudonym(&self, grant: &TaskGrant) -> Result { + let approver = serde_json::to_string(&( + grant.approver.issuer.as_str(), + grant.approver.subject.as_str(), + ))?; + self.identifiers + .audit_reference_hash("casework-principal-v1", "", &approver) + .map_err(|_| StoreError::Invalid) + } fn assertion(&self, grant: &TaskGrant, now: u64) -> Result { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; if now >= grant.expires_at { @@ -380,12 +392,14 @@ impl TaskAuthority { now.checked_add(registry_casework_core::TASK_ASSERTION_LIFETIME_SECONDS) .ok_or(StoreError::Invalid)?, ); + let approver = self.approver_pseudonym(grant)?; let mut payload = json!({"iss":self.config.issuer,"sub":grant.template.agent.subject,"aud":self.config.exchange_audience, "iat":now,"nbf":now,"exp":expires,"jti":Uuid::new_v4(),"registry_actor_kind":"agent", "registry_grant_id":grant.id,"registry_grant_authority":grant.authority,"registry_grant_source_issuer":grant.source_issuer, "registry_grant_client":grant.template.client,"registry_grant_resource":grant.template.resource, "registry_purpose":grant.template.purpose,"registry_grant_exp":grant.expires_at, - "registry_grant_bounds":grant.template.bounds,"scope":grant.template.scopes.join(" "),"identity":grant.subjects}); + "registry_grant_bounds":grant.template.bounds,"registry_approver":approver, + "scope":grant.template.scopes.join(" "),"identity":grant.subjects}); if let Some(context) = &grant.template.evidence_context { payload["evidence_tags"] = json!(context.requester_tags); payload["evidence_audience"] = json!(context.audience); @@ -746,7 +760,7 @@ mod evidence_assertion_tests { use std::collections::BTreeMap; #[test] - fn evidence_requester_context_is_signed_as_two_explicit_claims() { + fn task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit() { let mut key = registry_platform_crypto::generate_private_jwk( registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, ) @@ -762,6 +776,7 @@ mod evidence_assertion_tests { status_clients: BTreeMap::new(), }, key, + identifiers: registry_platform_audit::AuditKeyHasher::unkeyed_dev_only(), }; let template: TaskTemplate = serde_json::from_value(json!({ "id":"evidence-check", "version":"1", "label":"Check evidence", @@ -782,8 +797,8 @@ mod evidence_assertion_tests { authority: "statutory-caseworker-v1".into(), source_issuer: "https://casework.test".into(), approver: IssuerPrincipal { - issuer: "https://issuer.test".into(), - subject: "officer".into(), + issuer: "https://approver-issuer.test".into(), + subject: "approver-subject-canary".into(), }, approver_profile: "staff".into(), source_subject: SubjectRef { @@ -816,6 +831,16 @@ mod evidence_assertion_tests { payload["evidence_audience"], "https://relying.test/procedure" ); + let approver = + serde_json::to_string(&("https://approver-issuer.test", "approver-subject-canary")) + .unwrap(); + let expected_approver = registry_platform_audit::AuditKeyHasher::unkeyed_dev_only() + .audit_reference_hash("casework-principal-v1", "", &approver) + .unwrap(); + assert_eq!(payload["registry_approver"], expected_approver); + let serialized = payload.to_string(); + assert!(!serialized.contains("https://approver-issuer.test")); + assert!(!serialized.contains("approver-subject-canary")); assert!(payload.get("evidence_context").is_none()); } } diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index 9501135766..13ec28326c 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -211,6 +211,7 @@ async fn fixture_for_role(lifetime: u64, role: CaseworkRole) -> Fixture { ]), }, key, + identifiers: registry_platform_audit::AuditKeyHasher::unkeyed_dev_only(), }; let service = crate::CaseworkService::new( store.clone(), diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 77b93c8293..d10fbc9314 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -683,6 +683,7 @@ async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> status_clients: BTreeMap::from([("breg-status".into(), BREG_RESOURCE.into())]), }, key, + identifiers: registry_platform_audit::AuditKeyHasher::unkeyed_dev_only(), }; let service = crate::CaseworkService::new( store, 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 f0749250d5..34a39865ea 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -1812,6 +1812,7 @@ impl SyntheticAssertionAuthority { "scope": "evidence:invoke", "registry_actor_kind": "agent", "registry_grant_id": grant_id, + "registry_approver": "h:synthetic-approver", "registry_grant_authority": TASK_AUTHORITY_PROFILE, "registry_grant_client": TASK_CLIENT_ID, "registry_grant_resource": TOKEN_AUDIENCE, @@ -2437,6 +2438,10 @@ fn assert_stock_task_context(token: &str, authority_issuer: &str, grant_expires_ claims["registry_grant_client"] == TASK_CLIENT_ID, "the exchanged token retains the original grant client" ); + assert!( + claims["registry_approver"] == "h:synthetic-approver", + "the exchanged token retains the pseudonymous approver" + ); assert!( claims["registry_grant_resource"] == TOKEN_AUDIENCE, "the exchanged token retains the original grant resource" diff --git a/crates/registry-evidence/benches/audit_bench.rs b/crates/registry-evidence/benches/audit_bench.rs index 56c56cd98c..0a688700c0 100644 --- a/crates/registry-evidence/benches/audit_bench.rs +++ b/crates/registry-evidence/benches/audit_bench.rs @@ -53,6 +53,7 @@ fn sample_event() -> EvidenceAuditEvent { AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: Some(pseudonym(0x22)), + approver_pseudonym: Some(pseudonym(0x23)), }, vec![AuditSubject { role: "subject".to_string(), diff --git a/crates/registry-evidence/src/audit.rs b/crates/registry-evidence/src/audit.rs index 8500087cae..ddb8c0bee1 100644 --- a/crates/registry-evidence/src/audit.rs +++ b/crates/registry-evidence/src/audit.rs @@ -152,6 +152,7 @@ impl EvidenceAuthorizationRefusalAuditEvent { self.requester_pseudonym.clone(), client.clone(), self.grant_pseudonym.clone(), + None, self.operation.clone(), self.reason.clone(), ) @@ -194,6 +195,8 @@ pub struct AuditAuthority { pub kind: AuthorityKind, #[serde(skip_serializing_if = "Option::is_none")] pub grant_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approver_pseudonym: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -480,6 +483,11 @@ fn valid_batch_item_groups( .grant_pseudonym .as_ref() .is_some_and(|value| !valid_pseudonym(value)) + || group + .authority + .approver_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) || group.subjects.is_empty() || group.subjects.len() > 8 || group.subjects.iter().any(|subject| { @@ -727,6 +735,11 @@ impl EvidenceAuditEvent { .grant_pseudonym .as_ref() .is_some_and(|value| !valid_pseudonym(value)) + || self + .authority + .approver_pseudonym + .as_ref() + .is_some_and(|value| !valid_pseudonym(value)) || self.subjects.is_empty() || self.subjects.len() > 8 || !(16..=128).contains(&self.operation.len()) @@ -771,6 +784,7 @@ impl EvidenceAuditEvent { self.requester_pseudonym.clone(), client.clone(), self.authority.grant_pseudonym.clone(), + self.authority.approver_pseudonym.clone(), self.purpose.clone(), self.operation.clone(), AuthorizationOutcome::Allowed, @@ -1391,6 +1405,7 @@ mod tests { authority: AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, subjects: vec![AuditSubject { role: "subject".to_owned(), @@ -1646,6 +1661,7 @@ mod tests { AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, vec![AuditSubject { role: "subject".to_string(), @@ -1677,6 +1693,7 @@ mod tests { AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, vec![AuditSubject { role: "subject".to_owned(), @@ -1743,6 +1760,7 @@ mod tests { authority: AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, subjects: vec![AuditSubject { role: "subject".to_owned(), @@ -2812,6 +2830,14 @@ mod tests { log.pseudonym("grant-v1", "urn:example:trust", b"raw-grant-token-canary") .expect("grant pseudonym builds"), ), + approver_pseudonym: Some( + log.pseudonym( + "approver-v1", + "urn:example:trust", + b"raw-approver-token-canary", + ) + .expect("approver pseudonym builds"), + ), }, vec![AuditSubject { role: "subject".to_owned(), @@ -3387,6 +3413,7 @@ mod tests { "bundleRevision", "raw-requester-token-canary", "raw-grant-token-canary", + "raw-approver-token-canary", "raw-actor-token-canary", "person-id-raw-selector-canary", "source-private-canary", diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index d79d9b6e42..c1eef903ec 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -1138,6 +1138,7 @@ mod tests { "registry_grant_resource": "evidence-resource", "registry_grant_exp": now + 300, "registry_grant_bounds": {"type":"evidence", "requirement":"urn:example:requirement"}, + "registry_approver": "h:synthetic-approver", "evidence_tags": ["caseworker"], "evidence_audience": "https://relying-party.invalid" })) diff --git a/crates/registry-evidence/src/main.rs b/crates/registry-evidence/src/main.rs index 6c53997a85..6a593c9e98 100644 --- a/crates/registry-evidence/src/main.rs +++ b/crates/registry-evidence/src/main.rs @@ -6370,6 +6370,7 @@ mod tests { AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, vec![AuditSubject { role: "subject".to_owned(), diff --git a/crates/registry-evidence/src/observability.rs b/crates/registry-evidence/src/observability.rs index 853a51c397..3568176317 100644 --- a/crates/registry-evidence/src/observability.rs +++ b/crates/registry-evidence/src/observability.rs @@ -671,6 +671,7 @@ mod tests { AuditAuthority { kind: AuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, vec![AuditSubject { role: "subject".to_string(), diff --git a/crates/registry-evidence/src/runtime.rs b/crates/registry-evidence/src/runtime.rs index e6e2d6efca..eb1151a533 100644 --- a/crates/registry-evidence/src/runtime.rs +++ b/crates/registry-evidence/src/runtime.rs @@ -1188,7 +1188,15 @@ impl EvidenceRuntime { return Err(map_authority(error)); } }; - let audit = self.request_batch_item_audit_material(&issuance_scope, &resolved)?; + let audit = self.request_batch_item_audit_material( + &issuance_scope, + &resolved, + context + .grant() + .ok() + .flatten() + .map(registry_platform_oidc::GrantClaims::approver), + )?; authorized.push(AuthorizedRequestBatchItem { request, resolved, @@ -1702,6 +1710,11 @@ impl EvidenceRuntime { client_pseudonym, }, &resolved, + context + .grant() + .ok() + .flatten() + .map(registry_platform_oidc::GrantClaims::approver), format, )?; let requirement = self @@ -2457,6 +2470,7 @@ impl EvidenceRuntime { &self, scope: &str, resolved: &ResolvedAuthorization, + approver: Option<&str>, ) -> Result { let grant_pseudonym = resolved .grant_id @@ -2464,6 +2478,7 @@ impl EvidenceRuntime { .map(|grant| self.audit.pseudonym("grant", scope, grant.as_bytes())) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let approver_pseudonym = self.approver_audit_pseudonym(scope, resolved, approver)?; let subjects = resolved .subjects .iter() @@ -2486,6 +2501,7 @@ impl EvidenceRuntime { authority: AuditAuthority { kind: map_authority_kind(resolved.authority_kind), grant_pseudonym, + approver_pseudonym, }, subjects, }) @@ -3188,6 +3204,7 @@ impl EvidenceRuntime { scope: &str, caller: AuditCaller, resolved: &ResolvedAuthorization, + approver: Option<&str>, format: ResponseFormat, ) -> Result { let AuditCaller { @@ -3202,6 +3219,7 @@ impl EvidenceRuntime { .map(|grant| self.audit.pseudonym("grant", scope, grant.as_bytes())) .transpose() .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + let approver_pseudonym = self.approver_audit_pseudonym(scope, resolved, approver)?; let subjects = resolved .subjects .iter() @@ -3232,12 +3250,30 @@ impl EvidenceRuntime { authority: AuditAuthority { kind: map_authority_kind(resolved.authority_kind), grant_pseudonym, + approver_pseudonym, }, subjects, response_protection: map_response_protection(format), }) } + fn approver_audit_pseudonym( + &self, + scope: &str, + resolved: &ResolvedAuthorization, + approver: Option<&str>, + ) -> Result, RuntimeFailure> { + if resolved.grant_id.is_none() { + return Ok(None); + } + let approver = + approver.ok_or_else(|| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym"))?; + self.audit + .pseudonym("approver", scope, approver.as_bytes()) + .map(Some) + .map_err(|_| failure(ProblemCode::ServiceUnavailable, "audit-pseudonym")) + } + async fn append_authorization_refusal( &self, operation: &str, diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index ee66a48446..0dbf474e13 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -6627,6 +6627,10 @@ async fn reordered_grant_subjects_resolve_by_role_and_emit_declaration_order() { .map(|subject| subject["role"].as_str().expect("role is text").to_owned()) .collect::>(); assert_eq!(roles, ["child", "candidate-parent"]); + assert!(first_event["record"]["authority"]["approverPseudonym"] + .as_str() + .is_some()); + assert!(!audit.contains("h:synthetic-approver")); // The verifier accepts the expected subject set in any expectation order. let serialized = serde_json::to_vec(&jws).expect("JWS serializes"); @@ -9590,6 +9594,7 @@ fn parent_grant_claims_for(candidate: Value) -> Value { "type": "evidence", "requirement": "urn:example:fixture:requirement:legal-parent-relationship:v1" }, + "registry_approver": "h:synthetic-approver", "grant": {"candidate_parent": candidate} }) } @@ -11517,6 +11522,7 @@ fn audit_probe_event(index: usize) -> EvidenceAuditEvent { AuditAuthority { kind: AuditAuthorityKind::Statutory, grant_pseudonym: None, + approver_pseudonym: None, }, vec![AuditSubject { role: "subject".to_owned(), diff --git a/crates/registry-evidence/src/selector.rs b/crates/registry-evidence/src/selector.rs index 7f1debef28..1da3c2fc26 100644 --- a/crates/registry-evidence/src/selector.rs +++ b/crates/registry-evidence/src/selector.rs @@ -870,6 +870,10 @@ pub fn resolve_offline_fixture_authorization( names.grant_bounds.clone(), serde_json::json!({"type": "evidence", "requirement": request.requirement}), ); + object.insert( + names.approver.clone(), + Value::String("h:offline-fixture-approver".to_owned()), + ); let parsed: registry_platform_oidc::Claims = serde_json::from_value(grant_claims).map_err(|_| AuthorizationError::Unauthorized)?; let grant = registry_platform_oidc::grant_claims( diff --git a/crates/registry-evidence/tests/selector_conformance.rs b/crates/registry-evidence/tests/selector_conformance.rs index 5946f8d6ba..6b3a660399 100644 --- a/crates/registry-evidence/tests/selector_conformance.rs +++ b/crates/registry-evidence/tests/selector_conformance.rs @@ -1267,6 +1267,7 @@ fn task_grant_claims(authority: &str, subject: Value) -> Value { "type": "evidence", "requirement": "urn:example:fixture:requirement:property-with-event:v1" }, + "registry_approver": "h:synthetic-approver", "grant": {"subject": subject} }) } @@ -1528,6 +1529,7 @@ fn audit_authority(audit: &EvidenceAuditLog, resolved: &ResolvedAuthorization) - .pseudonym("grant", "selector-conformance", grant.as_bytes()) .expect("grant pseudonymizes") }), + approver_pseudonym: None, } } diff --git a/crates/registry-platform-audit/README.md b/crates/registry-platform-audit/README.md index ba84b03b6e..b6c004e122 100644 --- a/crates/registry-platform-audit/README.md +++ b/crates/registry-platform-audit/README.md @@ -88,8 +88,9 @@ async fn write_audit_event() -> Result<(), registry_platform_audit::AuditError> instead of concatenating ad hoc hash inputs in each service. Keep service semantics and canonicalization in the consuming service. - `AuthorizationAuditEvent` accepts only platform hash handles and Evidence's - established key-versioned pseudonyms for identity-bearing fields. It does - not derive keys or replace a product's pseudonym scope policy. + established key-versioned pseudonyms for principal, client, grant, and + approver identity fields. It does not derive keys or replace a product's + pseudonym scope policy. - Redaction helpers intentionally avoid preserving email local parts, phone digits, or sensitive query values. diff --git a/crates/registry-platform-audit/src/authorization.rs b/crates/registry-platform-audit/src/authorization.rs index 895612b8c0..6ce0092ed0 100644 --- a/crates/registry-platform-audit/src/authorization.rs +++ b/crates/registry-platform-audit/src/authorization.rs @@ -32,6 +32,8 @@ pub struct AuthorizationAuditEvent { #[serde(skip_serializing_if = "Option::is_none")] grant_pseudonym: Option, #[serde(skip_serializing_if = "Option::is_none")] + approver_pseudonym: Option, + #[serde(skip_serializing_if = "Option::is_none")] purpose: Option, operation: String, outcome: AuthorizationOutcome, @@ -45,6 +47,7 @@ impl AuthorizationAuditEvent { principal_pseudonym: impl Into, client_pseudonym: impl Into, grant_pseudonym: Option, + approver_pseudonym: Option, purpose: impl Into, operation: impl Into, outcome: AuthorizationOutcome, @@ -55,6 +58,7 @@ impl AuthorizationAuditEvent { principal_pseudonym: principal_pseudonym.into(), client_pseudonym: client_pseudonym.into(), grant_pseudonym, + approver_pseudonym, purpose: Some(purpose.into()), operation: operation.into(), outcome, @@ -72,6 +76,7 @@ impl AuthorizationAuditEvent { principal_pseudonym: impl Into, client_pseudonym: impl Into, grant_pseudonym: Option, + approver_pseudonym: Option, operation: impl Into, reason: impl Into, ) -> Result { @@ -80,6 +85,7 @@ impl AuthorizationAuditEvent { principal_pseudonym: principal_pseudonym.into(), client_pseudonym: client_pseudonym.into(), grant_pseudonym, + approver_pseudonym, purpose: None, operation: operation.into(), outcome: AuthorizationOutcome::Denied, @@ -109,6 +115,11 @@ impl AuthorizationAuditEvent { self.grant_pseudonym.as_deref() } + #[must_use] + pub fn approver_pseudonym(&self) -> Option<&str> { + self.approver_pseudonym.as_deref() + } + #[must_use] pub fn purpose(&self) -> Option<&str> { self.purpose.as_deref() @@ -139,6 +150,10 @@ impl AuthorizationAuditEvent { .grant_pseudonym .as_deref() .is_some_and(|value| !valid_pseudonym(value)) + || self + .approver_pseudonym + .as_deref() + .is_some_and(|value| !valid_pseudonym(value)) { return Err(AuthorizationAuditError::InvalidPseudonym); } @@ -166,6 +181,10 @@ impl fmt::Debug for AuthorizationAuditEvent { "grant_pseudonym", &self.grant_pseudonym.as_ref().map(|_| ""), ) + .field( + "approver_pseudonym", + &self.approver_pseudonym.as_ref().map(|_| ""), + ) .field("purpose", &"") .field("operation", &self.operation) .field("outcome", &self.outcome) @@ -240,6 +259,7 @@ mod tests { format!("hmac-sha256:{}", digest('a')), format!("hmac-sha256:v2:{}", digest('b')), Some(format!("hmac-sha256:v2:{}", digest('c'))), + Some(format!("hmac-sha256:v2:{}", digest('d'))), "benefit-review", "get", AuthorizationOutcome::Allowed, @@ -247,6 +267,10 @@ mod tests { ) .expect("valid event"); assert_eq!(event.actor_kind(), "agent"); + assert_eq!( + event.approver_pseudonym(), + Some(format!("hmac-sha256:v2:{}", digest('d')).as_str()) + ); assert_eq!(event.outcome(), AuthorizationOutcome::Allowed); } @@ -258,6 +282,7 @@ mod tests { format!("hmac-sha256:v2:{}", digest('a')), format!("hmac-sha256:v2:{}", digest('b')), None, + None, "benefit-review", operation, AuthorizationOutcome::Allowed, @@ -275,6 +300,7 @@ mod tests { "raw-principal-canary", format!("sha256:{}", digest('b')), None, + None, "review", "get", AuthorizationOutcome::Denied, @@ -291,6 +317,7 @@ mod tests { format!("sha256:{}", digest('a')), format!("sha256:{}", digest('b')), None, + None, "registry-operations", "list", AuthorizationOutcome::Denied, @@ -318,6 +345,7 @@ mod tests { format!("sha256:{}", digest('a')), format!("sha256:{}", digest('b')), None, + None, "evaluate", "authorization.profile", ) @@ -333,6 +361,7 @@ mod tests { format!("hmac-sha256:{}", digest('a')), format!("hmac-sha256:{}", digest('b')), Some(format!("hmac-sha256:{}", digest('c'))), + Some(format!("hmac-sha256:{}", digest('d'))), "sensitive-purpose-canary", "patch", AuthorizationOutcome::Allowed, @@ -344,6 +373,7 @@ mod tests { digest('a'), digest('b'), digest('c'), + digest('d'), "sensitive-purpose-canary".to_owned(), ] { assert!(!rendered.contains(&canary)); diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index aca3ab6bcc..2bffa829b4 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -72,9 +72,9 @@ async fn build_verifier() -> Result> { - If `allowed_clients` is set, `azp` takes precedence over `client_id`; `sub` is never used as a client identity. - Task-grant parsing is optional until any configured core grant claim is - present. Once present, every core member and both token and grant deadlines - are required. Product runtimes still own trusted-authority mappings and the - supported operation vocabulary. + present. Once present, every core member, the configured approver claim, and + both token and grant deadlines are required. Product runtimes still own + trusted-authority mappings and the supported operation vocabulary. - Store replay state, authorization decisions, and tenant boundaries in the consuming service. diff --git a/crates/registry-platform-oidc/src/authorization_claims.rs b/crates/registry-platform-oidc/src/authorization_claims.rs index fa800e68b7..8a9bcf4626 100644 --- a/crates/registry-platform-oidc/src/authorization_claims.rs +++ b/crates/registry-platform-oidc/src/authorization_claims.rs @@ -242,7 +242,7 @@ pub struct GrantClaims { purpose: String, exp: u64, bounds: GrantBounds, - approver: Option, + approver: String, } impl GrantClaims { @@ -292,8 +292,8 @@ impl GrantClaims { } #[must_use] - pub fn approver(&self) -> Option<&str> { - self.approver.as_deref() + pub fn approver(&self) -> &str { + &self.approver } /// Bind immutable grant context to identities already selected by the verifier. @@ -329,7 +329,7 @@ impl fmt::Debug for GrantClaims { .field("purpose", &"") .field("exp", &self.exp) .field("bounds", &self.bounds) - .field("approver", &self.approver.as_ref().map(|_| "")) + .field("approver", &"") .finish() } } @@ -466,7 +466,7 @@ pub fn grant_claims( let bounds: GrantBounds = serde_json::from_value(bounds_value.clone()) .map_err(|_| ClaimError::Malformed(ClaimMember::GrantBounds))?; bounds.validate()?; - let approver = optional_extra_string(claims, &names.approver, ClaimMember::Approver)?; + let approver = required_extra_string(claims, &names.approver, ClaimMember::Approver)?; Ok(Some(GrantClaims { principal, @@ -503,20 +503,6 @@ fn required_extra_string_with_bound( } } -fn optional_extra_string( - claims: &Claims, - name: &str, - member: ClaimMember, -) -> Result, ClaimError> { - match claims.extra.get(name) { - None => Ok(None), - Some(Value::String(value)) if valid_text(value, MAX_CLAIM_VALUE_BYTES) => { - Ok(Some(value.clone())) - } - Some(_) => Err(ClaimError::Malformed(member)), - } -} - fn required_standard_string( value: Option<&str>, member: ClaimMember, @@ -641,6 +627,21 @@ mod tests { } } + #[test] + fn grant_without_approver_is_rejected() { + let mut grant = + complete_grant(json!({"type":"evidence","requirement":"urn:requirement:one"})); + grant + .as_object_mut() + .expect("grant is an object") + .remove("registry_approver"); + + assert_eq!( + grant_claims(&claims(grant), &ClaimNames::default(), 1_500), + Err(ClaimError::Missing(ClaimMember::Approver)) + ); + } + #[test] fn custom_direct_names_map_without_nested_path_interpretation() { let names = ClaimNames { diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index 134fb6c6b6..d1ffee3e90 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -23,4 +23,4 @@ invariants: - {id: CASEWORK-SEC-15, invariants: [11, 18], state: enforced, targetWave: mvp, threat: A stale timer or recompute fires an effect for completed work or repeats an effect after restart., enforcementPoint: pinned clock calculation and generation with transactional source revision and occurrence fences, refusal: Cancel terminal or superseded clocks and reject stale claims or recompute previews before recording reminders or local reassignment., negativeId: CASEWORK-NEG-15, negativeTest: {path: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}} - {id: CASEWORK-SEC-16, invariants: [1, 3, 17, 18], state: enforced, targetWave: mvp, threat: A requester or former team member records an outcome or a policy change alters an existing hosted item., enforcementPoint: pinned hosted kind policy with separate Requester and human deciding profiles and transactional membership checks, refusal: Refuse unauthorized decisions and isolate requester feeds while cancellation and decision commit only one terminal result., negativeId: CASEWORK-NEG-16, negativeTest: {path: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}} - {id: CASEWORK-SEC-17, invariants: [6, 9, 17, 18], state: enforced, targetWave: mvp, threat: Retained payloads or cached replay responses disclose erased source or hosted data or allow rediscovery to restore it., enforcementPoint: explicit source erasure and hosted retention with transactional tombstones and bounded cursor cleanup, refusal: Preserve live attempt recovery before erasure and refuse expired replay without returning retained payloads or rehydrating erased source items., negativeId: CASEWORK-NEG-17, negativeTest: {path: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}} - - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human and restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval and standard token exchange, refusal: Refuse bootstrap and status clients at the source human profile and refuse revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} + - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, a keyed pseudonymous approver claim, and standard token exchange, refusal: Refuse bootstrap and status clients at the source human profile and refuse revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index 85f2f05cde..0a0832865e 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -19,4 +19,4 @@ entries: - {id: CASEWORK-SEC-15, tests: [{file: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}]} - {id: CASEWORK-SEC-16, tests: [{file: crates/registry-casework/tests/hosted_postgres.rs, name: pinned_policy_and_commit_time_membership_control_decision}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: cancel_and_decision_race_produces_one_stable_terminal_event}, {file: crates/registry-casework/tests/hosted_standalone.rs, name: ten_items_two_create_retries_and_one_terminal_result_without_breg}]} - {id: CASEWORK-SEC-17, tests: [{file: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}, {file: crates/registry-casework/tests/hosted_postgres.rs, name: terminal_cursors_and_independent_retention_are_enforced_and_erased}]} - - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} + - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} diff --git a/products/evidence/contracts/audit-event.schema.yaml b/products/evidence/contracts/audit-event.schema.yaml index 976019f6ae..a16d6a7250 100644 --- a/products/evidence/contracts/audit-event.schema.yaml +++ b/products/evidence/contracts/audit-event.schema.yaml @@ -41,6 +41,7 @@ properties: properties: kind: {enum: [statutory, organizational, consent, delegated, explicit-request]} grantPseudonym: {$ref: '#/$defs/pseudonym'} + approverPseudonym: {$ref: '#/$defs/pseudonym'} subjects: type: array minItems: 1 @@ -174,11 +175,11 @@ audit_rules: chain_verification: At startup and after restart, the newest sealed segment supplies the prior chain head and the complete active segment is verified from that head. Complete retained-history verification is an out-of-band operator duty performed with evidence verify-audit across every available sealed segment and, when the writer is stopped, the active segment. Steady-state appends and readiness verify the pinned active-segment identity, modification fingerprint, expected length, verified tail, and lock-file identity without rescanning sealed history or the growing active segment. external_mutation: Any external replacement or modification of the active segment or lock file fails readiness and future appends closed for the process lifetime. Mutation within older sealed history is detected by complete out-of-band verification, not by serving-process startup or readiness. pre_authentication_and_validation_failure: Authentication, malformed-request, and invalid-selector failures remain operational-only because the core has no authenticated requester plus authorization decision from which to construct the minimal refusal event. - pseudonym_domains: [requester, client, actor, grant, subject] + pseudonym_domains: [requester, client, actor, grant, approver, subject] subject_scope: key version plus operator trust domain, purpose, the binding scope, role, profile, and complete canonical selector bundle. The binding scope is the authenticated audience for an audience-scoped requirement and the holder key thumbprint for a holder-bound one; the two are domain-separated, so no binding can be read as belonging to the other mode. - pseudonym_scope: Requester, actor, and grant pseudonyms for an audience-scoped operation bind the operator trust domain, requested purpose, and authenticated audience. A holder-bound operation is scoped to no relying party, so its pseudonyms bind the operator trust domain and requested purpose under a separate derivation domain, and never the holder key thumbprint. A holder-bound requester pseudonym is therefore stable across that requester's issuances within one trust domain and purpose, which is the accountability property the mode keeps and the linkability it accepts. + pseudonym_scope: Requester, actor, grant, and approver pseudonyms for an audience-scoped operation bind the operator trust domain, requested purpose, and authenticated audience. A holder-bound operation is scoped to no relying party, so its pseudonyms bind the operator trust domain and requested purpose under a separate derivation domain, and never the holder key thumbprint. A holder-bound requester pseudonym is therefore stable across that requester's issuances within one trust domain and purpose, which is the accountability property the mode keeps and the linkability it accepts. never_record: - - raw principal, actor, grant, selector, source, or supported values + - raw principal, actor, grant, approver, selector, source, or supported values - separate hashes of low-entropy selector fields - credentials, tokens, request or response bodies - the request nonce diff --git a/products/evidence/contracts/request-batch-audit-event.schema.yaml b/products/evidence/contracts/request-batch-audit-event.schema.yaml index 8826999cec..8b75de63d4 100644 --- a/products/evidence/contracts/request-batch-audit-event.schema.yaml +++ b/products/evidence/contracts/request-batch-audit-event.schema.yaml @@ -74,6 +74,7 @@ $defs: properties: kind: {enum: [statutory, organizational, consent, delegated, explicit-request]} grantPseudonym: {$ref: '#/$defs/pseudonym'} + approverPseudonym: {$ref: '#/$defs/pseudonym'} subject: type: object additionalProperties: false @@ -160,7 +161,7 @@ allOf: audit_rules: item_indices: Every itemIndices array is strictly increasing. Indices are zero-based positions in the outer request. item_group_partition: itemGroups are ordered by their first index. Their itemIndices are non-overlapping and partition the event's relevant item set exactly. An access event's relevant set is its top-level itemIndices. A release event's relevant set is every item index from zero through outcomes.length minus one. - item_group_equivalence: Items share a group exactly when their complete authority object and ordered pseudonymized subjects array are identical. Items resolving through different grants or authority kinds are never forced into one group and do not invalidate the outer request. + item_group_equivalence: Items share a group exactly when their complete authority object, including grant and approver pseudonyms when present, and ordered pseudonymized subjects array are identical. Items resolving through different grants, approvers, or authority kinds are never forced into one group and do not invalidate the outer request. access_gate: One access event is durably accepted before every physical source call. Sequential fallback therefore records one index per call. An optimized source batch records the complete bounded index set carried by its one call. A physical call that never begins records no access event. outcome_order: outcomes contains one member for every request item in exact request order. itemIndex values are the contiguous sequence beginning at zero. evidenceId is required exactly for evidence and forbidden for evidence-not-available. signingKeyId is required exactly when at least one outcome is evidence and is forbidden for an all-unavailable release, because no assertion was signed. release_gate: The exact complete response envelope is serialized and checked against its 1048576-byte ceiling, then one disclosure-release event is durably accepted, then those same bytes are returned unchanged. There is exactly one terminal event for an authorized outer request and never one release per item. @@ -169,6 +170,7 @@ audit_rules: never_record: - request nonces - raw selectors, source values, facts, or bodies + - raw approver identities or signed approver handles - signed JWS material, signatures, payloads, or protected headers - credentials, tokens, script inputs or outputs, or signing material - candidates, counts, scores, hints, comparisons, or intermediate lookup identifiers diff --git a/products/evidence/contracts/security-invariant-matrix.yaml b/products/evidence/contracts/security-invariant-matrix.yaml index 78334497aa..a0e904b5c9 100644 --- a/products/evidence/contracts/security-invariant-matrix.yaml +++ b/products/evidence/contracts/security-invariant-matrix.yaml @@ -23,9 +23,9 @@ invariants: enforcement: Strict authentication profile, one configured principal claim, and one strict shared actor-kind claim with no fallback. negative_test: sec-missing-principal-no-fallback - id: V1-I05 - rule: One authorization decision binds requester, actor kind, optional actor identity, verified client, requirement revision, purpose, all role/profile/origin tuples, authority, and audience. A task-grant path additionally binds immutable grant id, principal, original client and resource, trusted source issuer, deadline, and exact Evidence requirement bound. - threat: Permission splicing or confused-deputy access across partially authorized dimensions. - enforcement: Exact complete-entitlement match before authorized-material audit, credential resolution, or source access. A present invalid grant cannot fall back to a standing profile. A refusal after successful authentication follows the separate minimal denial-audit invariant. + rule: One authorization decision binds requester, actor kind, optional actor identity, verified client, requirement revision, purpose, all role/profile/origin tuples, authority, and audience. A task-grant path additionally binds immutable grant id, principal, original client and resource, trusted source issuer, deadline, and exact Evidence requirement bound; requires its signed approver handle; and records a scoped keyed approver pseudonym on successful audit events. + threat: Permission splicing, confused-deputy access across partially authorized dimensions, or approved delegation without accountable approver attribution. + enforcement: Exact complete-entitlement match before authorized-material audit, credential resolution, or source access. A present grant is incomplete without its approver claim, and a present invalid grant cannot fall back to a standing profile. Successful grant audit derives the approver pseudonym with the existing scoped audit key. A refusal after successful authentication follows the separate minimal denial-audit invariant. negative_test: sec-no-entitlement-union - id: V1-I06 rule: Selector profiles and values are provider-lookup inputs, never proof of authority. diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 3fb5570a03..4d9f693034 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -49,6 +49,7 @@ entries: - {file: crates/registry-evidence/src/config.rs, name: complete_authority_paths_cannot_be_unioned_across_partial_grants} - {file: crates/registry-evidence/src/config.rs, name: task_grant_profiles_bind_trusted_source_and_verified_client} - {file: crates/registry-evidence/tests/selector_conformance.rs, name: task_grant_context_is_bound_before_selector_or_source_access} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: reordered_grant_subjects_resolve_by_role_and_emit_declaration_order} - id: sec-authenticated-authorization-refusal-audited tests: - {file: crates/registry-evidence/src/audit.rs, name: audit_contract_schema_accepts_each_native_shape_and_rejects_mixed_shapes} From c5300925358c646063ac21ccbf80102779f7480b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:45:29 +0700 Subject: [PATCH 108/120] fix(casework): refuse delegated officer sessions Signed-off-by: Jeremi Joslin --- crates/registry-casework/src/auth.rs | 10 +++ .../src/task_grants/http_tests.rs | 73 +++++++++++++++++++ .../tests/human_identity_auth.rs | 53 ++++++++++++++ 3 files changed, 136 insertions(+) diff --git a/crates/registry-casework/src/auth.rs b/crates/registry-casework/src/auth.rs index 25c3253115..5faebf7ba2 100644 --- a/crates/registry-casework/src/auth.rs +++ b/crates/registry-casework/src/auth.rs @@ -90,6 +90,16 @@ impl CaseworkAuthenticator { .profiles .get(selected_profile) .ok_or(AuthenticationError::Profile)?; + if profile.role != CaseworkRole::Requester + && (verified.claims.extra.contains_key("act") + || verified + .claims + .extra + .keys() + .any(|key| key.starts_with("registry_grant_"))) + { + return Err(AuthenticationError::Refused); + } let actual_scopes: BTreeSet<_> = verified.scopes.iter().map(String::as_str).collect(); if !profile .required_scopes diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index 13ec28326c..f4e42c43da 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -573,6 +573,79 @@ async fn task_http_approval_assertion_status_and_revocation_enforce_current_auth .unwrap(); } +#[tokio::test] +async fn delegated_or_grant_bearing_human_tokens_cannot_approve_or_revoke_task_grants() { + let f = fixture(900).await; + let human = token("human", "human-client", "human", "casework:staff"); + let base = format!("/v1/work-items/{}/task-grants", f.item); + let approval = json!({"templateId":"summary","templateVersion":"1"}); + let (status, grant) = request( + &f, + "POST", + &base, + &human, + true, + Some(approval.clone()), + Some("valid-approval"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let grant_id = grant["id"].as_str().unwrap(); + let revoke = format!("{base}/{grant_id}/revoke"); + + for (key, extra) in [ + ("delegated-approval", json!({"act": {"sub": "agent"}})), + ( + "grant-bearing-approval", + json!({"registry_grant_id": "delegated-grant"}), + ), + ] { + let mut claims = json!({ + "sub": "human", + "azp": "human-client", + "registry_actor_kind": "human", + "scope": "casework:staff" + }); + claims + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let credential = token_claims(claims); + assert_eq!( + request( + &f, + "POST", + &base, + &credential, + true, + Some(approval.clone()), + Some(key), + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + request(&f, "POST", &revoke, &credential, true, None, None) + .await + .0, + StatusCode::UNAUTHORIZED + ); + } + + assert!( + !f.store + .task_grant(Uuid::parse_str(grant_id).unwrap()) + .await + .unwrap() + .invalidated + ); + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn supervisor_task_grant_requires_continued_supervisor_membership() { let f = fixture_for_role(900, CaseworkRole::Supervisor).await; diff --git a/crates/registry-casework/tests/human_identity_auth.rs b/crates/registry-casework/tests/human_identity_auth.rs index dd39810342..9fe4f41d1b 100644 --- a/crates/registry-casework/tests/human_identity_auth.rs +++ b/crates/registry-casework/tests/human_identity_auth.rs @@ -179,6 +179,59 @@ async fn trusted_human_assertion_gates_human_profiles_and_requester_accepts_serv idp.stop().await; } +#[tokio::test] +async fn delegated_or_grant_bearing_tokens_are_refused_for_officer_profiles_only() { + let idp = MockIdp::start().await; + let authenticator = authenticator(&idp); + + for extra in [ + json!({"act": {"sub": "agent"}}), + json!({"registry_grant_id": "delegated-grant"}), + ] { + for (profile, scope) in [ + ("staff", "casework:staff"), + ("supervisor", "casework:supervisor"), + ("administrator", "casework:admin"), + ] { + let mut claims = json!({ + "aud": AUDIENCE, + "registry_principal": QUEUE_MEMBER_PRINCIPAL, + "scope": scope, + "registry_actor_kind": "human" + }); + claims + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + assert_eq!( + authenticator + .authenticate(&idp.mint_token(claims), profile) + .await + .expect_err("delegated authority cannot enter an officer session"), + AuthenticationError::Refused + ); + } + + let mut requester_claims = json!({ + "aud": AUDIENCE, + "registry_principal": QUEUE_MEMBER_PRINCIPAL, + "scope": "casework:request", + "registry_actor_kind": "service" + }); + requester_claims + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let requester = authenticator + .authenticate(&idp.mint_token(requester_claims), "requester") + .await + .expect("the Requester profile may carry delegated request context"); + assert_eq!(requester.role, CaseworkRole::Requester); + } + + idp.stop().await; +} + #[tokio::test] async fn same_role_profiles_with_distinct_principals_require_distinct_scopes() { let idp = MockIdp::start().await; From 7adba8c1d1b2124874f6fbf6aef9b6e55e73677f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:47:30 +0700 Subject: [PATCH 109/120] fix(audit): retain grant context on writes and refusals Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/api/actions.rs | 1 + crates/registry-breg/src/api/attachments.rs | 2 + crates/registry-breg/src/api/context.rs | 10 ++ crates/registry-breg/src/api/mod.rs | 8 +- crates/registry-breg/src/audit.rs | 140 ++++++++++++++++-- crates/registry-breg/src/mutation.rs | 4 + crates/registry-breg/src/mutation/action.rs | 3 + crates/registry-breg/src/mutation/request.rs | 1 + crates/registry-breg/src/postgres/context.rs | 14 ++ .../src/postgres/history_read.rs | 2 + crates/registry-breg/src/postgres/mutation.rs | 1 + crates/registry-breg/src/postgres/read.rs | 2 + .../src/postgres/revision_read.rs | 2 + crates/registry-breg/src/request_retention.rs | 1 + .../tests/postgres_task_grants.rs | 64 +++++++- crates/registry-evidence/src/auth.rs | 18 ++- crates/registry-evidence/src/runtime_tests.rs | 36 +++++ products/breg/TASK_GRANTS.md | 9 ++ products/breg/scripts/test-postgres.sh | 1 + products/breg/scripts/validate_product.py | 1 + .../contracts/security-test-traceability.yaml | 1 + 21 files changed, 304 insertions(+), 17 deletions(-) diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 624994658f..388068c9a4 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -310,6 +310,7 @@ async fn action_refusal( .record_action_refusal( &route.action_id, crate::audit::HttpRefusalAudit { + grant: crate::audit::GrantAuditContext::from_claims(claims), method: route.method, operation_id: &route.id, target_record: None, diff --git a/crates/registry-breg/src/api/attachments.rs b/crates/registry-breg/src/api/attachments.rs index 669a42bb2f..0b326576d0 100644 --- a/crates/registry-breg/src/api/attachments.rs +++ b/crates/registry-breg/src/api/attachments.rs @@ -102,6 +102,7 @@ async fn mutate( return attachment_refusal( mutations, crate::audit::HttpRefusalAudit { + grant: crate::audit::GrantAuditContext::from_claims(&claims), method: attachment_route.method, operation_id: &attachment_route.id, target_record: Some(record_id), @@ -120,6 +121,7 @@ async fn mutate( attachment_refusal( mutations, crate::audit::HttpRefusalAudit { + grant: surface.context.grant_audit().cloned(), method: attachment_route.method, operation_id: &attachment_route.id, target_record: Some(record_id), diff --git a/crates/registry-breg/src/api/context.rs b/crates/registry-breg/src/api/context.rs index 76df5d85f4..d8d928c58d 100644 --- a/crates/registry-breg/src/api/context.rs +++ b/crates/registry-breg/src/api/context.rs @@ -287,6 +287,7 @@ pub struct AuthorizedRequestContext { request_presence: Vec, submitter_targets: BTreeMap>, task_grant: Option, + grant_audit: Option, } impl AuthorizedRequestContext { @@ -305,6 +306,7 @@ impl AuthorizedRequestContext { request_presence: Vec::new(), submitter_targets: BTreeMap::new(), task_grant: None, + grant_audit: None, } } @@ -316,6 +318,14 @@ impl AuthorizedRequestContext { self } + pub(crate) fn with_grant_audit(mut self, claims: &VerifiedRequestClaims) -> Self { + self.grant_audit = crate::audit::GrantAuditContext::from_claims(claims); + self + } + pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { + self.grant_audit.as_ref() + } + pub(crate) fn with_task_grant(mut self, grant: Option) -> Self { self.task_grant = grant; self diff --git a/crates/registry-breg/src/api/mod.rs b/crates/registry-breg/src/api/mod.rs index d4076bc568..410b9d6a38 100644 --- a/crates/registry-breg/src/api/mod.rs +++ b/crates/registry-breg/src/api/mod.rs @@ -2273,6 +2273,7 @@ async fn audited_mutation_refusal( } match mutations .record_refusal(crate::audit::HttpRefusalAudit { + grant: context.grant_audit().cloned(), method: route.method, operation_id: &route.id, target_record, @@ -2312,6 +2313,7 @@ async fn audited_mutation_concealment( }; match mutations .record_refusal(crate::audit::HttpRefusalAudit { + grant: crate::audit::GrantAuditContext::from_claims(claims), method: route.method, operation_id: &route.id, target_record, @@ -2538,7 +2540,8 @@ fn authorize_direct_route_base<'a>( selected_profile.to_owned(), row_boundaries, ) - .with_task_grant(task_grant_binding(profile, claims).ok()?); + .with_task_grant(task_grant_binding(profile, claims).ok()?) + .with_grant_audit(claims); let submitter_targets = profile .submitter_targets .iter() @@ -2634,7 +2637,8 @@ fn authorize_read_path_route<'a>( selected_profile.to_owned(), row_boundaries, ) - .with_task_grant(task_grant_binding(profile, claims).ok()?), + .with_task_grant(task_grant_binding(profile, claims).ok()?) + .with_grant_audit(claims), readable_fields, read_path: Some(read_path), }) diff --git a/crates/registry-breg/src/audit.rs b/crates/registry-breg/src/audit.rs index 32d026c8af..8fc847755b 100644 --- a/crates/registry-breg/src/audit.rs +++ b/crates/registry-breg/src/audit.rs @@ -18,6 +18,73 @@ use crate::postgres::{ ExpectedRegistryIdentity, RegistryLockKey, }; +/// Verified grant context retained only for minimized, keyed audit projection. +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct GrantAuditContext { + actor_kind: registry_platform_oidc::ActorKind, + grant: registry_platform_oidc::GrantClaims, +} +impl std::fmt::Debug for GrantAuditContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GrantAuditContext()") + } +} +impl GrantAuditContext { + pub(crate) fn from_claims(claims: &crate::api::VerifiedRequestClaims) -> Option { + Some(Self { + actor_kind: claims.actor_kind()?, + grant: claims.grant()?.clone(), + }) + } + + fn record( + &self, + profile: &AuditProfile, + scope: &str, + operation: &str, + allowed: bool, + ) -> Result { + use registry_platform_audit::{AuthorizationAuditEvent, AuthorizationOutcome}; + let hasher = profile.key_hasher(); + let pseudonym = |domain, value| { + hasher + .audit_reference_hash(domain, scope, value) + .map_err(|_| RegistryAuditError::InvalidContext) + }; + let event = AuthorizationAuditEvent::new( + self.actor_kind.as_str(), + pseudonym("breg-principal-v1", self.grant.principal())?, + pseudonym("breg-client-v1", self.grant.client())?, + Some(pseudonym("breg-grant-v1", self.grant.id())?), + Some(pseudonym("breg-approver-v1", self.grant.approver())?), + self.grant.purpose(), + operation, + if allowed { + AuthorizationOutcome::Allowed + } else { + AuthorizationOutcome::Denied + }, + if allowed { + "authorization.allowed" + } else { + "authorization.refused" + }, + ) + .map_err(|_| RegistryAuditError::InvalidContext)?; + let mut value = + serde_json::to_value(event).map_err(|_| RegistryAuditError::InvalidContext)?; + // BREG records purpose presence, never the purpose value. + value + .as_object_mut() + .ok_or(RegistryAuditError::InvalidContext)? + .remove("purpose"); + value["authority"] = json!(self.grant.authority()); + value["sourceIssuer"] = json!(self.grant.source_issuer()); + value["expiresAt"] = json!(self.grant.exp()); + Ok(value) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PreIoAuditKind { Attempt, @@ -37,6 +104,7 @@ pub struct PreIoAudit<'a> { } pub(crate) struct HttpRefusalAudit<'a> { + pub grant: Option, pub method: HttpMethod, pub operation_id: &'a str, pub target_record: Option<&'a str>, @@ -56,6 +124,7 @@ pub enum RegistryAuditError { } pub(crate) struct TerminalAudit { + pub grant: Option, pub outcome: TerminalAuditOutcome, pub method: HttpMethod, pub operation_id: String, @@ -181,6 +250,16 @@ pub async fn record_pre_io_audit( "principalReference": principal_reference, "recordReference": record_reference, }); + if event.kind == PreIoAuditKind::Refusal { + if let Some(grant) = claims.grant_audit() { + record["authorization"] = grant.record( + profile, + &expected.package_revision, + event.operation_id, + false, + )?; + } + } insert_refusal_reason(&mut record, event.refusal_reason); append_envelope(transaction.transaction(), profile, record).await?; transaction @@ -396,6 +475,17 @@ async fn record_http_refusal_audit_inner( Value::Bool(event.purpose_present), ), ]); + if let Some(grant) = &event.grant { + record.insert( + "authorization".to_owned(), + grant.record( + profile, + &expected.package_revision, + event.operation_id, + false, + )?, + ); + } if let Some(slot_id) = attachment_slot { record.insert( "attachment".to_owned(), @@ -451,7 +541,7 @@ pub(crate) async fn append_terminal_audit( append_envelope( transaction, profile, - Value::Object(terminal_record(terminal)), + Value::Object(terminal_record(terminal, profile)?), ) .await } @@ -472,7 +562,7 @@ pub(crate) async fn append_attachment_terminal_audit( { return Err(RegistryAuditError::InvalidContext); } - let mut record = terminal_record(terminal); + let mut record = terminal_record(terminal, profile)?; record.insert( "attachment".to_owned(), serde_json::json!({"slotId": slot, "proposalVersion": proposal_version}), @@ -488,13 +578,14 @@ pub(crate) async fn append_action_terminal_audit( terminal: TerminalAudit, application_reference: &str, ) -> Result<(), RegistryAuditError> { - let record = action_terminal_record(terminal, application_reference)?; + let record = action_terminal_record(terminal, application_reference, profile)?; append_envelope(transaction, profile, Value::Object(record)).await } fn action_terminal_record( terminal: TerminalAudit, application_reference: &str, + profile: &AuditProfile, ) -> Result, RegistryAuditError> { if terminal.entity_id.is_some() || terminal.action_id.as_deref().is_none_or(|id| id.is_empty()) @@ -507,7 +598,7 @@ fn action_terminal_record( { return Err(RegistryAuditError::InvalidContext); } - let mut record = terminal_record(terminal); + let mut record = terminal_record(terminal, profile)?; record.insert( "applicationReference".to_owned(), Value::String(application_reference.to_owned()), @@ -633,7 +724,22 @@ fn webhook_disposition_name(disposition: WebhookAuditDisposition) -> &'static st } } -fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { +fn terminal_record( + terminal: TerminalAudit, + profile: &AuditProfile, +) -> Result, RegistryAuditError> { + let authorization = terminal + .grant + .as_ref() + .map(|grant| { + grant.record( + profile, + &terminal.package_revision, + &terminal.operation_id, + terminal.outcome != TerminalAuditOutcome::Refused, + ) + }) + .transpose()?; let mut record = serde_json::Map::from_iter([ ( "schema".to_owned(), @@ -683,6 +789,9 @@ fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { Value::Bool(terminal.purpose_present), ), ]); + if let Some(authorization) = authorization { + record.insert("authorization".to_owned(), authorization); + } if let Some(entity_id) = terminal.entity_id { record.insert("entityId".to_owned(), Value::String(entity_id)); } @@ -713,7 +822,7 @@ fn terminal_record(terminal: TerminalAudit) -> serde_json::Map { Value::String(field_set_reference), ); } - record + Ok(record) } pub(crate) struct ReadTerminalAudit { @@ -727,7 +836,7 @@ pub(crate) async fn append_read_terminal_audit( profile: &AuditProfile, read_terminal: ReadTerminalAudit, ) -> Result<(), RegistryAuditError> { - let mut terminal = terminal_record(read_terminal.terminal); + let mut terminal = terminal_record(read_terminal.terminal, profile)?; if let Some(query_reference) = read_terminal.query_reference { terminal.insert("queryReference".to_owned(), Value::String(query_reference)); } @@ -834,8 +943,13 @@ fn method_name(method: HttpMethod) -> &'static str { mod action_terminal_tests { use super::*; + fn profile() -> AuditProfile { + AuditProfile::production_from_secret_bytes(vec![9; 32].into()).unwrap() + } + fn terminal(outcome: TerminalAuditOutcome) -> TerminalAudit { TerminalAudit { + grant: None, outcome, method: HttpMethod::Post, operation_id: "actions.register.invoke".to_owned(), @@ -859,8 +973,9 @@ mod action_terminal_tests { TerminalAuditOutcome::Committed, TerminalAuditOutcome::Replayed, ] { - let record = action_terminal_record(terminal(outcome), "protected-application") - .expect("action terminal has protected application provenance"); + let record = + action_terminal_record(terminal(outcome), "protected-application", &profile()) + .expect("action terminal has protected application provenance"); assert_eq!(record["applicationReference"], "protected-application"); assert_eq!(record["actionId"], "register"); assert_eq!(record["resultCount"], 0); @@ -875,18 +990,19 @@ mod action_terminal_tests { let mut entity = terminal(TerminalAuditOutcome::Committed); entity.entity_id = Some("item".to_owned()); assert_eq!( - action_terminal_record(entity, "protected-application"), + action_terminal_record(entity, "protected-application", &profile()), Err(RegistryAuditError::InvalidContext) ); assert_eq!( action_terminal_record( terminal(TerminalAuditOutcome::Returned), - "protected-application" + "protected-application", + &profile() ), Err(RegistryAuditError::InvalidContext) ); assert_eq!( - action_terminal_record(terminal(TerminalAuditOutcome::Committed), ""), + action_terminal_record(terminal(TerminalAuditOutcome::Committed), "", &profile()), Err(RegistryAuditError::InvalidContext) ); } diff --git a/crates/registry-breg/src/mutation.rs b/crates/registry-breg/src/mutation.rs index 4e014c786f..294dda428a 100644 --- a/crates/registry-breg/src/mutation.rs +++ b/crates/registry-breg/src/mutation.rs @@ -1605,6 +1605,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: request.claims.grant_audit().cloned(), outcome: TerminalAuditOutcome::Replayed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -1757,6 +1758,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: request.claims.grant_audit().cloned(), outcome: TerminalAuditOutcome::Committed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -1847,6 +1849,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: request.claims.grant_audit().cloned(), outcome: TerminalAuditOutcome::Replayed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), @@ -2021,6 +2024,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: request.claims.grant_audit().cloned(), outcome: TerminalAuditOutcome::Committed, method: request.plan.route.method, operation_id: request.plan.route.id.clone(), diff --git a/crates/registry-breg/src/mutation/action.rs b/crates/registry-breg/src/mutation/action.rs index f1f500d508..f67e1e446c 100644 --- a/crates/registry-breg/src/mutation/action.rs +++ b/crates/registry-breg/src/mutation/action.rs @@ -468,6 +468,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: None, outcome: TerminalAuditOutcome::Committed, method: HttpMethod::Post, operation_id: route_id.to_owned(), @@ -564,6 +565,7 @@ impl MutationCoordinator { transaction, &self.audit_profile, TerminalAudit { + grant: None, outcome: TerminalAuditOutcome::Replayed, method: HttpMethod::Post, operation_id: route_id.to_owned(), @@ -1017,6 +1019,7 @@ impl MutationCoordinator { transaction.transaction(), &self.audit_profile, TerminalAudit { + grant: None, outcome: TerminalAuditOutcome::Returned, method: HttpMethod::Post, operation_id: route_id.to_owned(), diff --git a/crates/registry-breg/src/mutation/request.rs b/crates/registry-breg/src/mutation/request.rs index f3566e6204..f05316a23e 100644 --- a/crates/registry-breg/src/mutation/request.rs +++ b/crates/registry-breg/src/mutation/request.rs @@ -1865,6 +1865,7 @@ impl MutationCoordinator { outcome: TerminalAuditOutcome, ) -> TerminalAudit { TerminalAudit { + grant: claims.grant_audit().cloned(), outcome, method: route.method, operation_id: route.id.clone(), diff --git a/crates/registry-breg/src/postgres/context.rs b/crates/registry-breg/src/postgres/context.rs index 8de40c34a6..aa06b9c0a5 100644 --- a/crates/registry-breg/src/postgres/context.rs +++ b/crates/registry-breg/src/postgres/context.rs @@ -196,6 +196,7 @@ pub struct ClaimContext { canonical_row_boundaries: String, submitter_targets: BTreeMap, task_grant: Option, + grant_audit: Option, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -221,6 +222,17 @@ impl SpatialBboxContext { } impl ClaimContext { + pub(crate) fn with_grant_audit( + mut self, + grant: Option, + ) -> Self { + self.grant_audit = grant; + self + } + pub(crate) fn grant_audit(&self) -> Option<&crate::audit::GrantAuditContext> { + self.grant_audit.as_ref() + } + pub(crate) fn with_task_grant( mut self, grant: crate::task_grant::TaskGrantBinding, @@ -311,6 +323,7 @@ impl ClaimContext { canonical_row_boundaries, submitter_targets: BTreeMap::new(), task_grant: None, + grant_audit: None, }) } @@ -343,6 +356,7 @@ impl ClaimContext { canonical_row_boundaries, submitter_targets: BTreeMap::new(), task_grant: None, + grant_audit: None, }) } diff --git a/crates/registry-breg/src/postgres/history_read.rs b/crates/registry-breg/src/postgres/history_read.rs index 88c9120840..808353af52 100644 --- a/crates/registry-breg/src/postgres/history_read.rs +++ b/crates/registry-breg/src/postgres/history_read.rs @@ -451,6 +451,7 @@ impl PostgresSnapshotReadService { &self.audit_profile, ReadTerminalAudit { terminal: TerminalAudit { + grant: None, outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -504,6 +505,7 @@ impl SnapshotReadService for PostgresSnapshotReadService { &self.expected, &self.audit_profile, crate::audit::HttpRefusalAudit { + grant: None, method: request.method, operation_id: &request.operation_id, target_record: request.target_record.as_deref(), diff --git a/crates/registry-breg/src/postgres/mutation.rs b/crates/registry-breg/src/postgres/mutation.rs index 6ba5a1b3c9..75774357c3 100644 --- a/crates/registry-breg/src/postgres/mutation.rs +++ b/crates/registry-breg/src/postgres/mutation.rs @@ -676,6 +676,7 @@ fn strict_claim_context( context.purpose().map(str::to_owned), row_boundaries, ) + .map(|claims| claims.with_grant_audit(context.grant_audit().cloned())) .and_then(|claims| claims.with_api_submitter_targets(registry, context)) .and_then(|claims| match context.task_grant() { Some(grant) => claims.with_task_grant(grant.clone()), diff --git a/crates/registry-breg/src/postgres/read.rs b/crates/registry-breg/src/postgres/read.rs index 371ea63122..4230042dd9 100644 --- a/crates/registry-breg/src/postgres/read.rs +++ b/crates/registry-breg/src/postgres/read.rs @@ -925,6 +925,7 @@ impl PostgresRecordReadService { &request.selected_fields, )?; Ok(TerminalAudit { + grant: None, outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -1022,6 +1023,7 @@ impl RecordReadService for PostgresRecordReadService { &self.expected, &self.audit_profile, crate::audit::HttpRefusalAudit { + grant: None, method: request.method, operation_id: &request.operation_id, target_record: request.target_record.as_deref(), diff --git a/crates/registry-breg/src/postgres/revision_read.rs b/crates/registry-breg/src/postgres/revision_read.rs index 8e1bc05ed3..18165f9393 100644 --- a/crates/registry-breg/src/postgres/revision_read.rs +++ b/crates/registry-breg/src/postgres/revision_read.rs @@ -314,6 +314,7 @@ impl PostgresRevisionReadService { &self.audit_profile, ReadTerminalAudit { terminal: TerminalAudit { + grant: None, outcome, method: request.method, operation_id: request.operation_id.clone(), @@ -374,6 +375,7 @@ impl RevisionReadService for PostgresRevisionReadService { &self.expected, &self.audit_profile, crate::audit::HttpRefusalAudit { + grant: None, method: request.method, operation_id: &request.operation_id, target_record: request.target_record.as_deref(), diff --git a/crates/registry-breg/src/request_retention.rs b/crates/registry-breg/src/request_retention.rs index 727e3d7b0f..123037d7e3 100644 --- a/crates/registry-breg/src/request_retention.rs +++ b/crates/registry-breg/src/request_retention.rs @@ -1458,6 +1458,7 @@ async fn append_retention_audit( transaction, profile, TerminalAudit { + grant: None, outcome: TerminalAuditOutcome::Committed, method: HttpMethod::Delete, operation_id: RETENTION_OPERATION_ID.to_owned(), diff --git a/crates/registry-breg/tests/postgres_task_grants.rs b/crates/registry-breg/tests/postgres_task_grants.rs index 501d6cda3d..b110b549bf 100644 --- a/crates/registry-breg/tests/postgres_task_grants.rs +++ b/crates/registry-breg/tests/postgres_task_grants.rs @@ -308,7 +308,7 @@ fn app( } fn human(idp: &MockIdp, subject: &str, purpose: &str) -> String { idp.mint_token( - json!({"aud":AUDIENCE,"client_id":"human-client","sub":subject,"tenant_claim":"tenant-a","registry_purpose":purpose}), + json!({"aud":AUDIENCE,"client_id":"human-client","registry_actor_kind":"human","sub":subject,"tenant_claim":"tenant-a","registry_purpose":purpose}), ) } fn agent(idp: &MockIdp, status: &Status) -> (String, String) { @@ -327,7 +327,7 @@ fn agent(idp: &MockIdp, status: &Status) -> (String, String) { let subjects = json!({"tenant_claim":"tenant-a"}); let binding:TaskGrantBinding=serde_json::from_value(json!({"grantId":id,"authority":"casework","sourceIssuer":SOURCE,"principal":"agent-subject","client":"task-agent","resource":AUDIENCE,"purpose":"review","bounds":bounds,"subjects":subjects,"expiresAt":expires})).unwrap(); status.bindings.lock().unwrap().insert(id.clone(), binding); - let token=idp.mint_token(json!({"aud":AUDIENCE,"sub":"agent-subject","client_id":"task-agent","registry_actor_kind":"agent","registry_grant_id":id,"registry_grant_authority":"casework","registry_grant_source_issuer":SOURCE,"registry_grant_client":"task-agent","registry_grant_resource":AUDIENCE,"registry_purpose":"review","registry_grant_exp":expires,"registry_grant_bounds":bounds,"identity":subjects})); + let token=idp.mint_token(json!({"aud":AUDIENCE,"sub":"agent-subject","client_id":"task-agent","registry_actor_kind":"agent","registry_grant_id":id,"registry_approver":"synthetic-approver","registry_grant_authority":"casework","registry_grant_source_issuer":SOURCE,"registry_grant_client":"task-agent","registry_grant_resource":AUDIENCE,"registry_purpose":"review","registry_grant_exp":expires,"registry_grant_bounds":bounds,"identity":subjects})); (id, token) } struct Response { @@ -467,6 +467,64 @@ async fn counts(db: &TestDatabase) -> Vec { } counts } +async fn assert_grant_audit(db: &TestDatabase, grant: &str, phase: &str, outcome: &str) { + let hasher = AuditProfile::production_from_secret_bytes(vec![0x9a; 32].into()) + .unwrap() + .key_hasher(); + let pseudonym = hasher + .audit_reference_hash("breg-grant-v1", REVISION, grant) + .unwrap(); + let rows = db + .admin + .query( + "SELECT convert_from(envelope, 'UTF8') FROM registry_internal.registry_audit", + &[], + ) + .await + .unwrap(); + let records: Vec = rows + .iter() + .map(|row| serde_json::from_str::(row.get(0)).unwrap()["record"].clone()) + .collect(); + let record = records + .iter() + .find(|record| { + record["phase"] == phase && record["authorization"]["grantPseudonym"] == pseudonym + }) + .expect("grant-bound write and refusal retain minimized grant audit context"); + let authorization = &record["authorization"]; + assert_eq!(authorization["outcome"], outcome); + assert_eq!(authorization["authority"], "casework"); + assert_eq!(authorization["sourceIssuer"], SOURCE); + assert!(authorization["expiresAt"].as_u64().is_some()); + assert!(authorization["approverPseudonym"] + .as_str() + .unwrap() + .starts_with("hmac-sha256:")); + for field in ["grantPseudonym", "principalPseudonym", "clientPseudonym"] { + assert!(authorization[field] + .as_str() + .unwrap() + .starts_with("hmac-sha256:")); + } + let rendered = authorization.to_string(); + for sensitive in [ + grant, + "agent-subject", + "task-agent", + "tenant-a", + "subjects", + "bounds", + "synthetic-approver", + "purpose", + ] { + assert!( + !rendered.contains(sensitive), + "audit must not contain {sensitive}" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn task_http_to_postgres_preserves_original_authority_and_completed_receipts() { let db = TestDatabase::create(8).await; @@ -526,6 +584,7 @@ async fn task_http_to_postgres_preserves_original_authority_and_completed_receip before_calls + 1, "HTTP grant must reach the SQL coordinator" ); + assert_grant_audit(&db, &grant, "terminal", "allowed").await; let record = id(&draft); let read = get(&app, &record, "submitter", &token).await; let submit = action(&read, "submit_request"); @@ -567,6 +626,7 @@ async fn task_http_to_postgres_preserves_original_authority_and_completed_receip .status, StatusCode::PRECONDITION_FAILED ); + assert_grant_audit(&db, &grant, "refusal", "denied").await; assert_eq!(counts(&db).await, before); let call_count = status.calls(); let recovered=create(&app,"/v1/records/correction-requests?accessProfile=submitter",&token,&create_key,json!({"tenant":"tenant-a","placement":id(&target),"proposedSite":id(&new),"reason":"synthetic correction"})).await; diff --git a/crates/registry-evidence/src/auth.rs b/crates/registry-evidence/src/auth.rs index c1eef903ec..d4a5c1cb51 100644 --- a/crates/registry-evidence/src/auth.rs +++ b/crates/registry-evidence/src/auth.rs @@ -111,6 +111,7 @@ pub struct AuthenticatedContext { requester_tags: Vec, evidence_audience: String, task_grant: Result, TaskGrantError>, + audit_grant_id: Option, verified_claims: Value, } @@ -153,7 +154,9 @@ impl AuthenticatedContext { } pub fn grant_id(&self) -> Option<&str> { - self.grant().ok().flatten().map(GrantClaims::id) + self.audit_grant_id + .as_deref() + .or_else(|| self.grant().ok().flatten().map(GrantClaims::id)) } pub fn grant_authority(&self) -> Option<&str> { @@ -192,6 +195,7 @@ impl AuthenticatedContext { requester_tags, evidence_audience: evidence_audience.to_owned(), task_grant, + audit_grant_id: None, verified_claims, } } @@ -517,6 +521,17 @@ impl Authenticator { let claims = serde_json::to_value(&verified.claims).map_err(|_| AuthenticationError::Context)?; let claims_object = claims.as_object().ok_or(AuthenticationError::Context)?; + // Retain a bounded identifier from the verified token for denial audit + // even when expiry or another grant member fails validation. This is + // never an authorization input and is emitted only as a keyed pseudonym. + let audit_grant_id = optional_direct_string( + claims_object, + &self.claims.contextual_claims.grant_id, + MAX_PRINCIPAL_BYTES, + ) + .ok() + .flatten() + .filter(|id| !id.chars().any(char::is_control)); // Version one validates no proof of possession. Treating a // sender-constrained token as an ordinary bearer would silently discard @@ -583,6 +598,7 @@ impl Authenticator { requester_tags, evidence_audience, task_grant, + audit_grant_id, verified_claims: claims, }) } diff --git a/crates/registry-evidence/src/runtime_tests.rs b/crates/registry-evidence/src/runtime_tests.rs index 0dbf474e13..a324fac8f9 100644 --- a/crates/registry-evidence/src/runtime_tests.rs +++ b/crates/registry-evidence/src/runtime_tests.rs @@ -2362,6 +2362,42 @@ async fn authorization_refusal_is_minimally_audited() { } } +#[tokio::test] +async fn invalid_grant_refusal_keeps_only_a_valid_identifier_pseudonym() { + let fixture = acceptance_runtime().await; + let http = TestServer::new(build_app(Arc::clone(&fixture.runtime))); + for (identifier, keep) in [ + (json!("expired-grant-canary"), true), + (json!({"bad":"shape"}), false), + (json!(""), false), + ] { + let mut claims = parent_grant_claims(); + claims["registry_grant_id"] = identifier; + claims["registry_grant_exp"] = json!(Utc::now().timestamp() - 1); + http.post("/v1/evidence") + .add_header( + "authorization", + format!("Bearer {}", access_token(Some(claims))), + ) + .json(&parent_request()) + .await + .assert_status_forbidden(); + let audit = fs::read_to_string(&fixture.audit_path).unwrap(); + let event: Value = serde_json::from_str(audit.lines().last().unwrap()).unwrap(); + let pseudonym = event["record"]["grantPseudonym"].as_str(); + assert_eq!( + pseudonym.is_some(), + keep, + "a valid grant identifier survives grant refusal" + ); + if let Some(pseudonym) = pseudonym { + assert!(pseudonym.starts_with("hmac-sha256:")); + } + assert!(!audit.contains("expired-grant-canary")); + } + assert!(fixture.server.received_requests().await.unwrap().is_empty()); +} + #[tokio::test] async fn authorization_refusal_requester_pseudonym_stays_scoped() { let fixture = acceptance_runtime().await; diff --git a/products/breg/TASK_GRANTS.md b/products/breg/TASK_GRANTS.md index f0a2abda49..99ca0018a8 100644 --- a/products/breg/TASK_GRANTS.md +++ b/products/breg/TASK_GRANTS.md @@ -92,3 +92,12 @@ The bounded status check runs while the proposal is locked. Casework revocation can still occur between that check and the local commit. BREG and Casework do not share a distributed transaction. Proposal-detail erasure also removes its retained task subjects, under the existing operator retention boundary. +## Audit + +Terminal and refusal records for a request carrying a verified task grant include +an `authorization` object using the shared authorization audit fields. Grant, +principal, client and approver identifiers are keyed pseudonyms, scoped to the +package revision. The object also records the authority, source issuer and grant +deadline. It contains no subjects, bounds values or purpose value. BREG continues +to record purpose presence separately. Later human review remains a separate +actor, and the retained original grant continues to govern status checks. diff --git a/products/breg/scripts/test-postgres.sh b/products/breg/scripts/test-postgres.sh index fa51297ab8..182f12df4e 100755 --- a/products/breg/scripts/test-postgres.sh +++ b/products/breg/scripts/test-postgres.sh @@ -68,6 +68,7 @@ if [[ "$lane" == all || "$lane" == postgres ]]; then --test postgres_data_facility \ --test postgres_data_export \ --test postgres_change_requests \ + --test postgres_task_grants \ --test postgres_request_authority \ --test postgres_request_receipts \ --test postgres_request_upgrade_retention \ diff --git a/products/breg/scripts/validate_product.py b/products/breg/scripts/validate_product.py index d4010cde37..a70416ed3f 100644 --- a/products/breg/scripts/validate_product.py +++ b/products/breg/scripts/validate_product.py @@ -121,6 +121,7 @@ "cargo test --locked -p registry-breg --features postgres-test --test postgres_data_facility", "cargo test --locked -p registry-breg --features postgres-test --test postgres_data_export", "cargo test --locked -p registry-breg --features postgres-test --test postgres_change_requests", + "cargo test --locked -p registry-breg --features postgres-test --test postgres_task_grants", "cargo test --locked -p registry-breg --features postgres-test --test postgres_request_authority", "cargo test --locked -p registry-breg --features postgres-test --test postgres_request_receipts", "cargo test --locked -p registry-breg --features postgres-test --test postgres_request_upgrade_retention", diff --git a/products/evidence/contracts/security-test-traceability.yaml b/products/evidence/contracts/security-test-traceability.yaml index 4d9f693034..5375e1065d 100644 --- a/products/evidence/contracts/security-test-traceability.yaml +++ b/products/evidence/contracts/security-test-traceability.yaml @@ -54,6 +54,7 @@ entries: tests: - {file: crates/registry-evidence/src/audit.rs, name: audit_contract_schema_accepts_each_native_shape_and_rejects_mixed_shapes} - {file: crates/registry-evidence/src/runtime_tests.rs, name: authorization_refusal_is_minimally_audited} + - {file: crates/registry-evidence/src/runtime_tests.rs, name: invalid_grant_refusal_keeps_only_a_valid_identifier_pseudonym} - {file: crates/registry-evidence/src/runtime_tests.rs, name: authorization_refusal_audit_failure_returns_service_unavailable} - id: sec-selector-possession-no-authority tests: [{file: crates/registry-evidence/src/runtime_tests.rs, name: security_contract_rejects_unknown_and_unauthorized_requests_before_source_access}] From 2bf536b680d9dc65956d4fd2aa1e1d3b18a04c95 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:49:28 +0700 Subject: [PATCH 110/120] fix(casework): allow eligible officers to revoke grants Signed-off-by: Jeremi Joslin --- crates/registry-casework/src/http.rs | 9 +-- crates/registry-casework/src/task_grants.rs | 26 ++++++--- .../src/task_grants/http_tests.rs | 57 +++++++++++++++++++ products/casework/TASK_GRANTS.md | 4 +- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/crates/registry-casework/src/http.rs b/crates/registry-casework/src/http.rs index 9c3959d98d..490f2c17bb 100644 --- a/crates/registry-casework/src/http.rs +++ b/crates/registry-casework/src/http.rs @@ -1208,13 +1208,8 @@ async fn revoke_task_grant( if !body.is_empty() { return Err(HttpError::Invalid); } - let (actor, token) = authenticate(&state, &headers).await?; - Ok(Json( - state - .service - .revoke_task(&actor, item, grant, source_profile(&headers)?, token) - .await?, - )) + let (actor, _) = authenticate(&state, &headers).await?; + Ok(Json(state.service.revoke_task(&actor, item, grant).await?)) } async fn task_client( state: &HttpState, diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 618cd66f48..4ecbe7478e 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -30,17 +30,28 @@ async fn eligible( item: &WorkItem, template: &TaskTemplate, ) -> Result { - let Some(membership_kind) = membership_kind(actor.role) else { - return Ok(false); - }; - if !template.eligible_profiles.contains(&actor.profile_id) - || item.holder.as_ref() != Some(&actor.principal) + if item.holder.as_ref() != Some(&actor.principal) || !template.item_states.contains(&item.state) || item.subject.source_id != template.source || !template.item_kinds.contains(&item.subject.kind) { return Ok(false); } + eligible_officer(transaction, actor, item, template).await +} + +async fn eligible_officer( + transaction: &Transaction<'_>, + actor: &ActorContext, + item: &WorkItem, + template: &TaskTemplate, +) -> Result { + let Some(membership_kind) = membership_kind(actor.role) else { + return Ok(false); + }; + if !template.eligible_profiles.contains(&actor.profile_id) { + return Ok(false); + } let row = transaction.query_one( "SELECT EXISTS(SELECT 1 FROM casework_memberships m JOIN casework_queue_service q ON q.team_id=m.team_id WHERE m.issuer=$1 AND m.subject=$2 AND m.membership_kind=$3 AND m.team_id=ANY($4) AND q.queue_id=$5)", &[&actor.principal.issuer, &actor.principal.subject, &membership_kind, &template.eligible_teams, &item.queue_id], @@ -294,7 +305,7 @@ impl PostgresStore { .await? .get(0); let grant: TaskGrant = serde_json::from_value(record)?; - if !eligible(&transaction, actor, &item, &grant.template).await? { + if !eligible_officer(&transaction, actor, &item, &grant.template).await? { return Err(StoreError::Forbidden); } } @@ -599,10 +610,7 @@ impl crate::CaseworkService { actor: &ActorContext, item: Uuid, id: Uuid, - profile: &str, - token: &str, ) -> Result { - self.caller_item(actor, item, profile, token).await?; let stored = self.store.task_grant(id).await?; if stored.grant.item_id != item { return Err(crate::ServiceError::NotFound); diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index f4e42c43da..59bb0fea2d 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -62,6 +62,9 @@ impl SourceAdapter for Source { _: &str, _: EphemeralCredential<'_>, ) -> Result { + if self.mode.load(Ordering::SeqCst) == 5 { + return Err(SourceAdapterError::Unavailable); + } Ok(CallerSubjectView { display_reference: None, subject: subject.clone(), @@ -646,6 +649,60 @@ async fn delegated_or_grant_bearing_human_tokens_cannot_approve_or_revoke_task_g .unwrap(); } +#[tokio::test] +async fn eligible_officer_can_revoke_without_holding_the_item_or_reading_the_source() { + let f = fixture(900).await; + let holder = token("human", "human-client", "human", "casework:staff"); + let base = format!("/v1/work-items/{}/task-grants", f.item); + let (status, grant) = request( + &f, + "POST", + &base, + &holder, + true, + Some(json!({"templateId":"summary","templateVersion":"1"})), + Some("approval-for-revocation"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{grant}"); + let grant_id = grant["id"].as_str().unwrap(); + let revoke = format!("{base}/{grant_id}/revoke"); + + f.mode.store(5, Ordering::SeqCst); + let outsider = token("outsider", "human-client", "human", "casework:staff"); + assert_eq!( + request(&f, "POST", &revoke, &outsider, true, None, None) + .await + .0, + StatusCode::FORBIDDEN, + "a profile alone cannot revoke without eligible-team membership" + ); + + let db = f.store.client().await.unwrap(); + db.execute( + "INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,'revoker','staff')", + &[&ISSUER], + ) + .await + .unwrap(); + db.execute( + "UPDATE casework_meta SET directory_revision=directory_revision+1", + &[], + ) + .await + .unwrap(); + + let revoker = token("revoker", "human-client", "human", "casework:staff"); + let (status, body) = request(&f, "POST", &revoke, &revoker, true, None, None).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["invalidated"], true); + + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn supervisor_task_grant_requires_continued_supervisor_membership() { let f = fixture_for_role(900, CaseworkRole::Supervisor).await; diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index 3dccdecee9..8226d8e683 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -70,8 +70,10 @@ approval and a new grant ID. Listings omit retained subject selectors. ## Revocation and status -An eligible current holder calls +Any officer with a profile and team membership eligible under the grant template +for the item's queue can call `POST /v1/work-items/{itemId}/task-grants/{grantId}/revoke` with an empty body. +Revocation does not require holding the item or reading its source. Resource servers call `GET /v1/task-grants/{grantId}/status`; inactive status returns no grant detail. Machine callers cannot substitute resources or subjects. From 5911785698968cbbd56556638dc38926611eee5f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:56:28 +0700 Subject: [PATCH 111/120] fix(casework): align task purpose validation Signed-off-by: Jeremi Joslin --- crates/registry-casework-core/src/task_grant.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index 2976380a50..7d9dd697cc 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -162,6 +162,7 @@ impl TaskTemplate { .all(|byte| matches!(byte, 0x21 | 0x23..=0x5b | 0x5d..=0x7e)) }) || !bounded(&self.purpose, 128) + || !matches!(self.purpose.as_bytes().first(), Some(b'a'..=b'z')) || self.purpose.bytes().any(|byte| { !(byte.is_ascii_lowercase() || byte.is_ascii_digit() @@ -316,7 +317,7 @@ fn unique(values: &[String], maximum: usize) -> bool { mod tests { use super::*; #[test] - fn governed_scopes_require_explicit_bounded_oauth_names() { + fn governed_scopes_and_purposes_require_explicit_bounded_oauth_names() { let project: CaseworkProject = serde_json::from_value(serde_json::json!({ "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, "casework": {"id":"tasks", "version":"1"}, @@ -353,6 +354,16 @@ mod tests { "invalid governed OAuth scopes accepted" ); } + template.scopes = vec!["records:get".into()]; + for purpose in ["1-record-review", ":review"] { + template.purpose = purpose.into(); + assert!( + template.check(&project).is_err(), + "purpose refused by grant parsing was accepted: {purpose}" + ); + } + template.purpose = "record:review".into(); + assert!(template.check(&project).is_ok()); } #[test] From ae4496c4017ecf0e1fc949cff7d03c49d7149fcb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 10:58:54 +0700 Subject: [PATCH 112/120] fix(evidence): reject unused grant constraints Signed-off-by: Jeremi Joslin --- crates/registry-evidence/src/config.rs | 48 ++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/registry-evidence/src/config.rs b/crates/registry-evidence/src/config.rs index be1bf2cc39..22c9ff3b5e 100644 --- a/crates/registry-evidence/src/config.rs +++ b/crates/registry-evidence/src/config.rs @@ -1011,6 +1011,14 @@ impl EvidenceConfig { .insert(source_selector_set); } } + if !authority.uses_task_grant() + && (!authority.requester_clients.is_empty() + || authority.grant_source_issuer.is_some()) + { + return invalid( + "requesterClients and grantSourceIssuer require an authenticated-grant subject", + ); + } } for requirement in &self.requirements { @@ -3667,6 +3675,15 @@ pub struct AuthorityProfile { } impl AuthorityProfile { + fn uses_task_grant(&self) -> bool { + self.grants.iter().any(|grant| { + grant + .subjects + .iter() + .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant) + }) + } + fn validate(&self) -> Result<(), ConfigError> { validate_unique_strings(&self.requester_tags, 1, 32, 1, 128, "requester tags")?; if self.requester_tags.iter().any(|tag| !valid_local_id(tag)) { @@ -3684,13 +3701,7 @@ impl AuthorityProfile { for grant in &self.grants { grant.validate()?; } - let uses_task_grant = self.grants.iter().any(|grant| { - grant - .subjects - .iter() - .any(|subject| subject.value_origin == ValueOrigin::AuthenticatedGrant) - }); - if uses_task_grant { + if self.uses_task_grant() { validate_len( self.requester_clients.len(), 1, @@ -6435,6 +6446,29 @@ mod tests { ); } + #[test] + fn standing_authority_rejects_task_grant_only_constraints() { + let config = EvidenceConfig::parse_yaml(include_bytes!( + "../../../products/evidence/fixtures/acceptance/adult-status/evidence.yaml" + )) + .expect("standing-authority fixture validates"); + let expected = invalid( + "requesterClients and grantSourceIssuer require an authenticated-grant subject", + ); + + let mut requester_clients = config.clone(); + requester_clients.authentication.allowed_clients = Some(vec!["evidence-cli".to_owned()]); + requester_clients.authority_profiles.0[0] + .1 + .requester_clients = vec!["evidence-cli".to_owned()]; + assert_eq!(requester_clients.validate(), expected); + + let mut source_issuer = config; + source_issuer.authority_profiles.0[0].1.grant_source_issuer = + Some("https://casework.invalid".to_owned()); + assert_eq!(source_issuer.validate(), expected); + } + #[test] fn unauthenticated_source_is_local_loopback_only_and_matches_the_bundle_schema() { let mut local = EvidenceConfig::parse_yaml(include_bytes!( From 498576b3b130d10ce8aec974d9b6559fe6ff279e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 11:01:00 +0700 Subject: [PATCH 113/120] fix(breg): require trusted actor for agent actions Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/api/actions.rs | 3 + .../src/api/tests/immediate_action_tests.rs | 71 ++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/registry-breg/src/api/actions.rs b/crates/registry-breg/src/api/actions.rs index 388068c9a4..d9e15119c6 100644 --- a/crates/registry-breg/src/api/actions.rs +++ b/crates/registry-breg/src/api/actions.rs @@ -161,6 +161,9 @@ fn authorize_action<'a>( ) { return None; } + if expected == crate::contract::ActorKindSource::Agent && claims.actor_subject().is_none() { + return None; + } } if !grant.requester_clients.is_empty() && !claims diff --git a/crates/registry-breg/src/api/tests/immediate_action_tests.rs b/crates/registry-breg/src/api/tests/immediate_action_tests.rs index 79812444ae..0616ffa69f 100644 --- a/crates/registry-breg/src/api/tests/immediate_action_tests.rs +++ b/crates/registry-breg/src/api/tests/immediate_action_tests.rs @@ -11,7 +11,7 @@ use crate::postgres::{ RegistryLockKey, }; use registry_platform_httputil::FetchUrlPolicy; -use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig}; +use registry_platform_oidc::{ClaimNames, JwksFetcher, JwksFetcherConfig}; use registry_platform_testing::{oidc_verifier_config, MockIdp}; use std::time::{Duration, Instant}; use zeroize::Zeroizing; @@ -61,6 +61,19 @@ accessProfiles: operations: [invoke] targets: [{entity: case, rowBoundaries: []}] results: [] + - id: standing-agent + principalClaim: registry_principal + actorKind: agent + requesterClients: [agent-client] + requiredScopes: [case.rename] + requiredPurposes: [case-management] + permissions: + - action: rename-case + operations: [invoke] + targets: + - entity: case + rowBoundaries: [{field: region, claim: regions, operator: in}] + results: [renamed] "#; fn compiled() -> Arc { @@ -470,6 +483,62 @@ fn action_authority_has_no_crud_requirement_or_profile_fallback() { .is_empty()); } +#[tokio::test] +async fn standing_agent_action_requires_a_trusted_actor_subject() { + let registry = compiled(); + let idp = MockIdp::start().await; + let audience = "urn:example:breg"; + let actor = "00000000-0000-4000-8000-0000000000aa"; + let mut verifier = oidc_verifier_config(idp.issuer(), vec![audience.to_owned()]); + verifier.allowed_clients = vec!["agent-client".to_owned()]; + let auth = RegistryAuthenticator::new( + ®istry, + verifier, + Arc::new(JwksFetcher::new_with_fetch_url_policy( + idp.jwks_uri(), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + AuthorityClaimConfig::new("registry_principal", Some("registry_purpose".to_owned())) + .with_contextual_claims( + ClaimNames::default(), + BTreeMap::from([("agent-client".to_owned(), actor.to_owned())]), + ), + ) + .expect("standing agent verifier config is valid"); + let service = service_for(registry.clone(), true); + let route = registry + .actions() + .routes + .iter() + .find(|route| route.kind == ActionRouteKind::Invoke) + .unwrap(); + let options = QueryOptions::parse(Some("accessProfile=standing-agent"), false).unwrap(); + let claims = json!({ + "aud": audience, + "sub": "citizen-sub", + "azp": "agent-client", + "registry_principal": "citizen-sub", + "registry_actor_kind": "agent", + "registry_purpose": "case-management", + "regions": ["north"], + "scope": "case.rename" + }); + let missing_actor = auth + .authenticate(&idp.mint_token(claims.clone())) + .await + .expect("standing agent token without act still authenticates"); + assert!(authorize_action(&service, route, &missing_actor, &options).is_none()); + + let mut trusted_claims = claims; + trusted_claims["act"] = json!({"sub": actor}); + let trusted_actor = auth + .authenticate(&idp.mint_token(trusted_claims)) + .await + .expect("registered client and actor pair authenticates"); + assert!(authorize_action(&service, route, &trusted_actor, &options).is_some()); +} + #[tokio::test] async fn action_only_discovery_is_profile_filtered_without_entity_read_access() { let registry = compiled(); From b98c69038c5dd485853faded2fde3ca1267767e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 11:36:49 +0700 Subject: [PATCH 114/120] fix(evidence): validate OAuth resource bytes at profile load Signed-off-by: Jeremi Joslin --- crates/registry-evidence-client/src/profile.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/registry-evidence-client/src/profile.rs b/crates/registry-evidence-client/src/profile.rs index 1846c67216..2da6c7de35 100644 --- a/crates/registry-evidence-client/src/profile.rs +++ b/crates/registry-evidence-client/src/profile.rs @@ -9,7 +9,7 @@ use std::{ }; use registry_platform_crypto::PrivateJwk; -use registry_platform_httputil::is_cloud_metadata_ip; +use registry_platform_httputil::{is_cloud_metadata_ip, valid_resource_uri}; use serde::{Deserialize, Serialize}; use crate::{error::EvidenceClientError, prepare::MAXIMUM_IDENTIFIER_BYTES, JwksDocument}; @@ -445,14 +445,7 @@ impl OauthProfile { .as_deref() .is_none_or(valid_expected_identity) && self.resource.as_deref().is_none_or(|value| { - !value.is_empty() - && value.len() <= MAXIMUM_PROFILE_REFERENCE_BYTES - && url::Url::parse(value).is_ok_and(|url| { - !url.scheme().is_empty() - && url.fragment().is_none() - && url.username().is_empty() - && url.password().is_none() - }) + value.len() <= MAXIMUM_PROFILE_REFERENCE_BYTES && valid_resource_uri(value) }) && self.scopes.as_ref().is_none_or(|scopes| { !scopes.is_empty() @@ -604,6 +597,10 @@ mod tests { // resource indicator. r#"{"resource":"urn:registry:evidence#fragment"}"#, r#"{"resource":"https://user:pw@registry.example.org"}"#, + // Preserve the authored resource bytes. URL parsing would trim + // whitespace or percent-encode Unicode before validation. + r#"{"resource":" https://registry.example.org"}"#, + r#"{"resource":"https://registry.example.org/résumé"}"#, // Scopes are RFC 6749 scope-tokens, stated at least once, without // repetition. r#"{"scopes":[]}"#, From cb21c1b57c633112156f01ebaaba8a04529e90c5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 11:40:58 +0700 Subject: [PATCH 115/120] fix(breg): reject overlapping authority claim roles Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/auth.rs | 34 +++++++++++++++++++++++++ crates/registry-breg/tests/http_auth.rs | 34 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/crates/registry-breg/src/auth.rs b/crates/registry-breg/src/auth.rs index 9b6bf80848..2053f59537 100644 --- a/crates/registry-breg/src/auth.rs +++ b/crates/registry-breg/src/auth.rs @@ -99,6 +99,8 @@ pub enum AuthenticationConfigError { InvalidVerifierProfile, #[error("an authority claim mapping is invalid")] InvalidClaimMapping, + #[error("a contextual authority claim overlaps another configured claim role")] + ConflictingClaimMapping, #[error("a compiled anonymous access profile carries a principal claim, required scopes, required purposes, or row boundaries")] AnonymousProfileCarriesAuthority, #[error("the configured principal claim is not the principal claim a compiled access profile requires")] @@ -427,6 +429,38 @@ fn validate_claim_mapping( .contextual_claims .validate() .map_err(|_| AuthenticationConfigError::InvalidClaimMapping)?; + let ClaimNames { + actor_kind, + purpose: contextual_purpose, + grant_id, + grant_authority, + grant_source_issuer, + grant_client, + grant_resource, + grant_exp, + grant_bounds, + approver, + } = &claims.contextual_claims; + let contextual_claims_with_distinct_roles = [ + actor_kind, + grant_id, + grant_authority, + grant_source_issuer, + grant_client, + grant_resource, + grant_exp, + grant_bounds, + approver, + ]; + if contextual_claims_with_distinct_roles.iter().any(|name| { + *name == &claims.principal_claim + || *name == &verifier.scope_claim + || claims.purpose_claim.as_ref() == Some(*name) + }) || contextual_purpose == &claims.principal_claim + || contextual_purpose == &verifier.scope_claim + { + return Err(AuthenticationConfigError::ConflictingClaimMapping); + } if claims.trusted_actors.len() > 128 || claims.trusted_actors.iter().any(|(client, actor)| { !valid_config_value(client) diff --git a/crates/registry-breg/tests/http_auth.rs b/crates/registry-breg/tests/http_auth.rs index afd4906914..6d6f1f9d31 100644 --- a/crates/registry-breg/tests/http_auth.rs +++ b/crates/registry-breg/tests/http_auth.rs @@ -1468,6 +1468,40 @@ async fn constructor_rejects_empty_duplicate_reserved_and_incomplete_mappings() } } +#[tokio::test] +async fn constructor_rejects_contextual_claims_that_shadow_other_authority_roles() { + let harness = Harness::new().await; + let principal_collision = ClaimNames { + actor_kind: "registry_principal".to_owned(), + ..ClaimNames::default() + }; + let purpose_collision = ClaimNames { + grant_id: "purpose".to_owned(), + ..ClaimNames::default() + }; + let scope_collision = ClaimNames::default(); + + for (contextual, scope_claim) in [ + (principal_collision, "scope"), + (purpose_collision, "scope"), + (scope_collision, "registry_actor_kind"), + ] { + let mut verifier = verifier_config(&harness.idp); + verifier.scope_claim = scope_claim.to_owned(); + let claims = authority_claims().with_contextual_claims(contextual, BTreeMap::new()); + let error = authenticator_with_verifier(&harness.registry, &harness.idp, verifier, claims) + .expect_err("one claim cannot supply two authority roles"); + assert_eq!(error, AuthenticationConfigError::ConflictingClaimMapping); + assert!(error.to_string().contains("overlaps"), "{error}"); + } + + let shared_purpose = + AuthorityClaimConfig::new("registry_principal", Some("registry_purpose".to_owned())) + .with_contextual_claims(ClaimNames::default(), BTreeMap::new()); + authenticator(&harness.registry, &harness.idp, shared_purpose) + .expect("the contextual purpose may share the configured purpose role"); +} + /// The construction refusal is what an operator reads at startup, so each /// compiled-authority check reports itself. The message still carries no /// configured claim name or claim value. From 30a3951244af4eadfa075e63f89b05d73350f3d1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 12:04:52 +0700 Subject: [PATCH 116/120] fix(casework): align task grant bounds validation Signed-off-by: Jeremi Joslin --- crates/registry-casework-core/src/task_grant.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index 7d9dd697cc..399663c0cb 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -84,11 +84,11 @@ pub struct TaskPermission { impl TaskGrantBounds { pub fn check(&self) -> Result<(), TaskGrantError> { match self { - Self::Evidence { requirement } if bounded(requirement, 512) => Ok(()), + Self::Evidence { requirement } if bounded_grant_identifier(requirement, 512) => Ok(()), Self::Breg { permissions } if !permissions.is_empty() && permissions.len() <= 64 => { let mut collections = BTreeSet::new(); for permission in permissions { - if !bounded(&permission.collection, 512) + if !bounded_grant_identifier(&permission.collection, 512) || !collections.insert(&permission.collection) || !unique(&permission.operations, 32) || permission @@ -306,6 +306,9 @@ fn bounded(value: &str, maximum: usize) -> bool { && !value.chars().any(char::is_control) && !value.contains('*') } +fn bounded_grant_identifier(value: &str, maximum: usize) -> bool { + bounded(value, maximum) && !value.chars().any(char::is_whitespace) +} fn unique(values: &[String], maximum: usize) -> bool { !values.is_empty() && values.len() <= maximum @@ -385,6 +388,8 @@ mod tests { for value in [ serde_json::json!({"type":"breg","permissions":[]}), serde_json::json!({"type":"evidence","requirement":"*"}), + serde_json::json!({"type":"evidence","requirement":"urn:requirement:one review"}), + serde_json::json!({"type":"breg","permissions":[{"collection":"case records","operations":["get"]}]}), serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get","get"]}]}), serde_json::json!({"type":"breg","permissions":[{"collection":"records","operations":["get"]},{"collection":"records","operations":["list"]}]}), ] { From ffd3e56f1bf9a106105f5aa649784e3aa5572891 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 12:07:33 +0700 Subject: [PATCH 117/120] fix(evidence): reject reserved grants at wallet offers Signed-off-by: Jeremi Joslin --- crates/registry-evidence-oid4vci/src/authorizer.rs | 11 ++++++++--- crates/registry-evidence-oid4vci/src/service.rs | 12 ++++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/registry-evidence-oid4vci/src/authorizer.rs b/crates/registry-evidence-oid4vci/src/authorizer.rs index 0a2f9f0faa..7407a6361b 100644 --- a/crates/registry-evidence-oid4vci/src/authorizer.rs +++ b/crates/registry-evidence-oid4vci/src/authorizer.rs @@ -194,11 +194,16 @@ impl OfferAuthorizer for MintResourceServer { .duration_since(std::time::UNIX_EPOCH) .map_err(|_| AuthorizationError::Refused)? .as_secs(); - if !matches!(grant_claims(&verified.claims, &self.claims, now), Ok(None)) { + if !matches!(grant_claims(&verified.claims, &self.claims, now), Ok(None)) + || !matches!( + grant_claims(&verified.claims, &ClaimNames::default(), now), + Ok(None) + ) + { // A wallet offer creates a deferred bearer lifecycle whose // later redemption cannot recheck the task authority. No - // complete, partial, malformed, or expired task grant may - // cross this boundary. + // complete, partial, malformed, or expired task grant under + // either configured or reserved claim names may cross it. return Err(AuthorizationError::Refused); } // The scope gate runs only on the verified token's scope set: diff --git a/crates/registry-evidence-oid4vci/src/service.rs b/crates/registry-evidence-oid4vci/src/service.rs index 5982d61501..b9d7732bca 100644 --- a/crates/registry-evidence-oid4vci/src/service.rs +++ b/crates/registry-evidence-oid4vci/src/service.rs @@ -1756,13 +1756,21 @@ mod tests { Arc::new(authorizer), issuer.clone(), )))); - for state in ["ordinary", "malformed", "expired", "live"] { + for state in [ + "ordinary", + "malformed", + "expired", + "live", + "reserved-default", + ] { let mut claims = json!({ "iss":issuer_url, "aud":"https://wallet.example.org", "sub":"offer-client", "client_id":"offer-client", "iat":now, "exp":now+300, "registry_actor_kind": if state == "ordinary" { "service" } else { "agent" }, "registry_purpose":"delivery", }); - if state != "ordinary" { + if state == "reserved-default" { + claims["registry_grant_id"] = json!("grant-a"); + } else if state != "ordinary" { claims[&names.grant_id] = json!("grant-a"); claims[&names.grant_authority] = json!("authority-a"); claims[&names.grant_source_issuer] = json!("https://casework.example.org"); From a1e7d7b6341961dde71468239cc4281f54ebcc7b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 12:11:23 +0700 Subject: [PATCH 118/120] fix(casework): distinguish stale task approvals Signed-off-by: Jeremi Joslin --- crates/registry-casework/src/task_grants.rs | 5 +- .../src/task_grants/http_tests.rs | 70 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index 4ecbe7478e..f16bbdc01a 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -541,9 +541,12 @@ impl crate::CaseworkService { let (item, _) = self .caller_item(actor, item_id, source_profile, token) .await?; - if item.revision != revision || item.holder.as_ref() != Some(&actor.principal) { + if item.holder.as_ref() != Some(&actor.principal) { return Err(crate::ServiceError::Forbidden); } + if item.revision != revision { + return Err(StoreError::Conflict.into()); + } if !self .store .eligible_task_template(actor, item_id, template) diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index 59bb0fea2d..62a7e8d781 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -576,6 +576,76 @@ async fn task_http_approval_assertion_status_and_revocation_enforce_current_auth .unwrap(); } +#[tokio::test] +async fn task_approval_distinguishes_a_stale_holder_from_a_nonholder() { + let f = fixture(900).await; + let path = format!("/v1/work-items/{}/task-grants", f.item); + let approval = json!({"templateId":"summary","templateVersion":"1"}); + let db = f.store.client().await.unwrap(); + db.execute( + "INSERT INTO casework_memberships(team_id,issuer,subject,membership_kind) VALUES('team',$1,'other','staff')", + &[&ISSUER], + ) + .await + .unwrap(); + let nonholder = token("other", "human-client", "human", "casework:staff"); + assert_eq!( + request( + &f, + "POST", + &path, + &nonholder, + true, + Some(approval.clone()), + Some("nonholder-approval"), + ) + .await + .0, + StatusCode::FORBIDDEN + ); + + db.execute( + "UPDATE casework_items SET revision=2 WHERE item_id=$1", + &[&f.item], + ) + .await + .unwrap(); + assert_eq!( + request( + &f, + "POST", + &path, + &nonholder, + true, + Some(approval.clone()), + Some("stale-nonholder-approval"), + ) + .await + .0, + StatusCode::FORBIDDEN + ); + let holder = token("human", "human-client", "human", "casework:staff"); + assert_eq!( + request( + &f, + "POST", + &path, + &holder, + true, + Some(approval), + Some("stale-holder-approval"), + ) + .await + .0, + StatusCode::PRECONDITION_FAILED + ); + + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn delegated_or_grant_bearing_human_tokens_cannot_approve_or_revoke_task_grants() { let f = fixture(900).await; From 5137a69881c47d595ea5a8adcc4db46edc11decc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 12:28:38 +0700 Subject: [PATCH 119/120] docs(evidence): align institutional task-grant scope Signed-off-by: Jeremi Joslin --- products/evidence/CONCEPT.md | 93 ++++++++++++++++++++++------- products/evidence/IMPLEMENTATION.md | 2 +- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/products/evidence/CONCEPT.md b/products/evidence/CONCEPT.md index 4e14fdd7cf..48ab020ecf 100644 --- a/products/evidence/CONCEPT.md +++ b/products/evidence/CONCEPT.md @@ -84,9 +84,12 @@ Evidence runs behind an existing gateway or identity boundary and produces asser The same core may later sit behind an OOTS Data Service boundary. OOTS RegRep XML, Evidence Broker and DSD registration, Semantic Repository profiles, preview, AS4, and OOTS retention rules remain in an explicit interoperability profile. -### Delegated software agents +### Institutional software agents -AI agents may later invoke fixed evidence operations under an external, task-bound authority grant. The agent is an authenticated actor, not the source of authority. Agent protocols and orchestration remain outside the core. +Institutional software agents may invoke fixed evidence operations under an +external, task-bound authority grant. The agent is an authenticated actor, not +the source of authority. Agent protocols and orchestration remain outside the +core. These are profiles around one evidence engine, not separate product editions. @@ -145,7 +148,22 @@ to invoke. This is the same reasoning that already admits a fixed HTTP request: a bundle-fixed instruction to a source that no request input may reshape is not a query language, whatever syntax it happens to be written in. -Document evidence, credential status and revocation, transaction-bound replay protection, OOTS execution, a public requester-entitlement or definition catalog, searchable, mutable, aggregate, or federated catalogs, response-led multi-source fulfillment, source-planning scripts, and the delegated-agent grant profile of section 15.3 are explicitly deferred. A multi-verifier holder credential is not among them: section 15.8 defines the declared holder-bound subject binding that produces one, together with the privacy analysis that binding requires, and it still adds no credential lifecycle and no delivery protocol. A fixed set of sources the bundle declares and orders is not response-led and is included under section 15.7. Deferring that profile does not defer the optional delegated actor identity of section 8.1: version one carries an actor in the authenticated authority context and authorizes it there, but consumes no agent grant record and exposes no agent-facing operations. The closed requester-scoped definition response is not a catalog or authorization source. The closed public provider advertisement remains allowed because it is a package-derived publication for indexing, not a catalog runtime. +Document evidence, credential status and revocation, transaction-bound replay +protection, OOTS execution, citizen-to-agent delegation and federation, a +public requester-entitlement or definition catalog, searchable, mutable, +aggregate, or federated catalogs, response-led multi-source fulfillment, and +source-planning scripts are explicitly deferred. A multi-verifier holder +credential is not among them: section 15.8 defines the declared holder-bound +subject binding that produces one, together with the privacy analysis that +binding requires, and it still adds no credential lifecycle and no delivery +protocol. A fixed set of sources the bundle declares and orders is not +response-led and is included under section 15.7. Version one carries actor kind +and optional actor identity in the authenticated authority context and consumes +a complete externally issued task-grant context for configured institutional +agent authority. It exposes no agent-facing operations. The closed +requester-scoped definition response is not a catalog or authorization source. +The closed public provider advertisement remains allowed because it is a +package-derived publication for indexing, not a catalog runtime. ## 5. Design principles @@ -360,8 +378,9 @@ Each deployment supports one reviewed authentication profile. It produces a norm - requester principal; - configured requester attributes; -- optional delegated actor identity; -- authority basis and optional grant identifier; +- verified actor kind and optional actor identity; +- verified client; +- authority basis and optional authenticated task-grant context; - derived audience, which scopes the subject binding under the audience-scoped mode of section 8.6 and is not an input to a holder-bound one; - permitted purposes and requirement revisions; @@ -375,11 +394,14 @@ Before source access, Rust binds one decision over: ```text requester principal -+ optional delegated actor ++ actor kind and optional actor identity ++ verified client + requirement revision + purpose + subject roles, selector profiles, value origins, and authority + audience ++ optional task-grant principal, client, resource, source issuer, authority, + deadline, requirement bound, and approver ``` Every element must be authorized together. Authorization for a purpose does not automatically authorize every subject, requirement revision, or audience. @@ -466,9 +488,21 @@ types in the Evidence core. ### 8.4 Consent, statutory authority, and delegation -Evidence consumes an authenticated authority context. Its basis may be statutory authority, organizational authority, consent, delegation, or an OOTS explicit request. A per-request grant reference is optional because statutory flows may derive authority from the requester and configured procedure. Evidence does not issue, manage, revoke, or infer that authority. - -Where the basis is delegation, version one carries the actor identity in that context and confines an actor-bearing request to authority paths declared `delegated`. It does not resolve a delegating principal, enforce call constraints, or consume an agent grant record; section 15.3 covers those. +Evidence consumes an authenticated authority context. Its basis may be +statutory authority, organizational authority, consent, delegation, or an OOTS +explicit request. A per-request grant reference is optional because statutory +flows may derive authority from the requester and configured procedure. +Evidence does not issue, manage, revoke, or infer that authority. + +For an institutional agent task, Evidence consumes a complete signed grant +context issued by an external task authority. It confines the request to a +configured `delegated` authority path whose subjects derive from the +authenticated grant. The agent actor kind, authenticated principal, verified +client and resource, trusted source issuer, grant authority, purpose, exact +Evidence requirement bound, effective deadline, and signed approver handle +must all match that one path. An invalid present grant cannot fall back to +standing authority. Citizen-to-agent delegation and federation remain deferred +under section 15.3. A caller-supplied consent or approval reference never creates authority by itself. @@ -1740,7 +1774,10 @@ An OpenFn workflow calls Evidence as one atomic step and routes the minimized re ## 15. Future profiles and guarded extensions -The following capabilities require separate profiles or design decisions. They are not latent version-one features. +The following capabilities require separate profiles or design decisions. They +are not latent version-one features. Section 15.3 records the boundary between +the approved Version 1 institutional task-grant path and the personal delegation +that remains deferred; it does not introduce a second agent subsystem. ### 15.1 OOTS assertion-evidence profile @@ -1776,21 +1813,30 @@ where the relying party verifies possession against a challenge it issued and retained itself. What stays here is everything that would need server state: server-issued challenges, one-time consumption, and replay prevention. -### 15.3 Delegated-agent profile +### 15.3 Institutional task grants and deferred personal delegation -An AI agent is an authenticated workload actor operating under an external authority grant. The grant binds: +Version one supports an institutional software agent as an authenticated +workload actor operating under an external task grant. The complete signed grant +context binds the agent principal, verified client and Evidence resource, +trusted source issuer, configured grant authority, fixed requirement, purpose, +subject values, effective deadline, and approver. Evidence accepts it only on a +configured `delegated` authority path whose subjects use +`authenticated-grant`; a partial, malformed, expired, mismatched, or non-agent +grant is refused without falling back to standing authority. -- delegating principal; -- agent workload identity; -- fixed requirement; -- purpose; -- subject authority; -- audience; -- validity and call constraints. +The agent invokes the same fixed Evidence operations as any other authenticated +caller. It cannot submit a free-form evidence query. Prompt text, conversation +history, model names, and agent reasoning never enter Evidence or audit. -The agent invokes fixed operations such as `getAdultStatus` or `confirmLegalParentage`. It cannot submit a free-form evidence query. Prompt text, conversation history, model names, and agent reasoning never enter Evidence or audit. +Personal or citizen-to-agent delegation remains a future profile. It would need +to represent the delegating natural person, consent or other personal authority, +revocation and status semantics, and trust across institutional domains. None of +those semantics is inferred from the institutional task grant. -An MCP or other tool facade may compile static tool descriptions from the trusted bundle and call the JSON API. It remains outside the core. Direct delivery to the relying party may allow the agent to receive only a receipt rather than the assertion value. +An MCP or other tool facade may compile static tool descriptions from the +trusted bundle and call the JSON API. It remains outside the core. Direct +delivery to the relying party may allow the agent to receive only a receipt +rather than the assertion value. ### 15.4 Document evidence @@ -2320,6 +2366,9 @@ mandatory default and includes: acquisition postures with no overclaiming of minimization; - one strict OIDC access-token reference profile; - one reviewed statutory-agency subject-authority profile; +- authenticated institutional task-grant authority for agent workloads, bound + to an exact requester client, resource, source issuer, authority, requirement, + purpose, deadline, and approver; - configured identifier, compound demographic, and multi-role selector profiles with provider-owned `match`, `no_match`, and `ambiguous` outcomes; - bounded Rhai extraction and requirement-specific derivation; @@ -2452,6 +2501,8 @@ The complete Version 1 sequence is: - Add the selected authentication profile. - Add selector value-origin, subject-authority, and authorization enforcement. +- Add authenticated institutional task-grant authority for agent workloads, + with complete grant binding and no fallback from an invalid present grant. - Add a standalone minimal native audit event for every authorization refusal after successful authentication, durably accepted before the generic `403`. - Add production signing-key resolution, fail-closed signing, and public JWKS publication. diff --git a/products/evidence/IMPLEMENTATION.md b/products/evidence/IMPLEMENTATION.md index 1c62436fad..553191a537 100644 --- a/products/evidence/IMPLEMENTATION.md +++ b/products/evidence/IMPLEMENTATION.md @@ -924,7 +924,7 @@ follow-up issue. | Target-host handoff | A reviewed candidate with independently provisioned owner-only production secrets passes `evidencectl doctor --runtime-config `, `evidencectl test`, and real startup. One authorized synthetic-subject HTTP request yields a signed assertion that `evidence verify` accepts only under independent `production` policy and trusted keys; the resulting access and disclosure audit events pass `evidence verify-audit`. | | Issuer handoff | External HTTPS OIDC is independently configured. Registered-client token acquisition and Evidence acceptance pass; issuer, audience, scope and grant-boundary mismatches fail without credentials, selectors, or source values in output. Local tooling uses pinned stock ThunderID. | | Compose and bare-binary journey | The maintained Compose guidance mounts the candidate bundle unchanged and read-only, uses a distinct container runtime revision, separate read-only secrets, persistent audit storage, a private listener, and operator TLS. It documents service UID and secret modes, public HTTPS issuer routing, and image provenance without generating Compose output. The production tutorials execute from released bare binaries and include a real Curl boundary. | -| Stop boundary | No capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed beyond the explicitly closed acquisition kinds, each of which fixes every call it may make in configuration before any call is made. This includes document evidence, credential lifecycle, OID4VCI, status lists, presentation verification, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, agents or MCP, federation, workflow, a public requester-entitlement or definition catalog, searchable, mutable, aggregate, or federated catalogs, runtime bundle mutation, script-selected transport, response-led or general multi-call planning, an evidence-data call no declared acquisition fixed, response-led multi-source fulfillment, a policy engine, application database, message broker, or worker process. The package-derived public provider advertisement remains inside the boundary as a closed publication for external indexing, not a catalog runtime. | +| Stop boundary | No deferred capability from `CONCEPT.md` section 4 or section 15 is implemented or stubbed beyond the explicitly closed acquisition kinds, each of which fixes every call it may make in configuration before any call is made. This includes document evidence, credential lifecycle, OID4VCI, status lists, presentation verification, nonce or replay storage beyond stateless request-nonce echo and comparison, OOTS XML or AS4, citizen-to-agent delegation, agent runtimes or MCP, federation, workflow, a public requester-entitlement or definition catalog, searchable, mutable, aggregate, or federated catalogs, runtime bundle mutation, script-selected transport, response-led or general multi-call planning, an evidence-data call no declared acquisition fixed, response-led multi-source fulfillment, a policy engine, application database, message broker, or worker process. The package-derived public provider advertisement remains inside the boundary as a closed publication for external indexing, not a catalog runtime. | ## Required Version 1 acceptance tests From 01eb7f987d779c750347521689cc8ba6605e43c3 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 13 Sep 2026 12:29:33 +0700 Subject: [PATCH 120/120] fix(casework): invalidate grants after approver role changes Signed-off-by: Jeremi Joslin --- crates/registry-casework/src/task_grants.rs | 21 ++- .../src/task_grants/http_tests.rs | 124 ++++++++++++++++-- 2 files changed, 128 insertions(+), 17 deletions(-) diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index f16bbdc01a..db1b9e642b 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -231,6 +231,7 @@ impl PostgresStore { &self, grant: &TaskGrant, template: &TaskTemplate, + configured_approver_role: Option, ) -> Result { let mut client = self.client().await?; let transaction = client.transaction().await?; @@ -260,7 +261,8 @@ impl PostgresStore { profile_id: grant.approver_profile.clone(), role, }; - let valid = template_active(&transaction, template).await? + let valid = Some(role) == configured_approver_role + && template_active(&transaction, template).await? && eligible(&transaction, &actor, &item, template).await? && TaskProposalIdentity::from(&item.binding) == grant.proposal; if !valid { @@ -643,7 +645,17 @@ impl crate::CaseworkService { .await?; return Err(crate::ServiceError::Forbidden); }; - if !self.store.check_task_eligibility(grant, template).await? { + let configured_approver_role = self + .project + .access_profiles + .iter() + .find(|profile| profile.id == grant.approver_profile) + .map(|profile| profile.role); + if !self + .store + .check_task_eligibility(grant, template, configured_approver_role) + .await? + { return Err(crate::ServiceError::Forbidden); } let fields = template.subjects.values().cloned().collect::>(); @@ -670,7 +682,10 @@ impl crate::CaseworkService { return Err(crate::ServiceError::Forbidden); } // Recheck after source I/O so revocation during the read cannot release an assertion. - if !self.store.check_task_eligibility(grant, template).await? + if !self + .store + .check_task_eligibility(grant, template, configured_approver_role) + .await? || now_seconds()? >= grant.expires_at { return Err(crate::ServiceError::Forbidden); diff --git a/crates/registry-casework/src/task_grants/http_tests.rs b/crates/registry-casework/src/task_grants/http_tests.rs index 62a7e8d781..2834ca4aa8 100644 --- a/crates/registry-casework/src/task_grants/http_tests.rs +++ b/crates/registry-casework/src/task_grants/http_tests.rs @@ -133,6 +133,7 @@ struct Fixture { item: Uuid, profile_id: &'static str, template: TaskTemplate, + project: CaseworkProject, admin: tokio_postgres::Client, schema: String, store: PostgresStore, @@ -197,6 +198,21 @@ async fn fixture_for_role(lifetime: u64, role: CaseworkRole) -> Fixture { let item = Uuid::new_v4(); db.execute("INSERT INTO casework_items(item_id,source_id,subject_kind,subject_id,occurrence_kind,occurrence_key,binding,state,queue_id,holder_issuer,holder_subject,revision,first_observed_at,updated_at) VALUES($1,'source','request','request-1','review','review-1',$2,'claimed','review',$3,'human',1,now(),now())",&[&item,&serde_json::to_value(binding()).unwrap(),&ISSUER]).await.unwrap(); let mode = Arc::new(AtomicUsize::new(0)); + let app = test_app(&store, &project, mode.clone()); + Fixture { + app, + mode, + item, + profile_id, + template, + project, + admin, + schema, + store, + } +} + +fn test_app(store: &PostgresStore, project: &CaseworkProject, mode: Arc) -> Router { let mut key = registry_platform_crypto::generate_private_jwk( registry_platform_crypto::GeneratedKeyAlgorithm::Rs384, ) @@ -244,26 +260,16 @@ async fn fixture_for_role(lifetime: u64, role: CaseworkRole) -> Fixture { ); let jwks=serde_json::from_value(json!({"keys":[{"kty":"oct","kid":"test","alg":"HS256","use":"sig","k":"MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE"}]})).unwrap(); let authenticator = crate::CaseworkAuthenticator::new( - &project, + project, verifier, Arc::new(JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults())), crate::HumanIdentityConfig::default(), ); - let app = crate::router(crate::HttpState { + crate::router(crate::HttpState { service, authenticator: Arc::new(authenticator), - project: Arc::new(project), - }); - Fixture { - app, - mode, - item, - profile_id, - template, - admin, - schema, - store, - } + project: Arc::new(project.clone()), + }) } async fn request( f: &Fixture, @@ -576,6 +582,96 @@ async fn task_http_approval_assertion_status_and_revocation_enforce_current_auth .unwrap(); } +#[tokio::test] +async fn task_grants_do_not_survive_an_approver_profile_role_change() { + let mut f = fixture(900).await; + let human = token("human", "human-client", "human", "casework:staff"); + let agent = token("agent", "agent-client", "agent", "casework:grants:assert"); + let resource = token( + "resource", + "breg-status", + "service", + "casework:grants:status", + ); + let path = format!("/v1/work-items/{}/task-grants", f.item); + let approval = json!({"templateId":"summary","templateVersion":"1"}); + let (_, assertion_grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(approval.clone()), + Some("role-change-assertion"), + ) + .await; + let (_, status_grant) = request( + &f, + "POST", + &path, + &human, + true, + Some(approval), + Some("role-change-status"), + ) + .await; + let assertion_id = assertion_grant["id"].as_str().unwrap(); + let status_id = status_grant["id"].as_str().unwrap(); + + let original_app = f.app.clone(); + let mut changed_project = f.project.clone(); + changed_project.access_profiles[0].role = CaseworkRole::Supervisor; + let mut replacement_staff = changed_project.access_profiles[0].clone(); + replacement_staff.id = "replacement-staff".into(); + replacement_staff.role = CaseworkRole::Staff; + replacement_staff.required_scopes = vec!["casework:replacement-staff".into()]; + let mut administrator = replacement_staff.clone(); + administrator.id = "administrator".into(); + administrator.role = CaseworkRole::Administrator; + administrator.required_scopes = vec!["casework:admin".into()]; + changed_project + .access_profiles + .extend([replacement_staff, administrator]); + changed_project + .check() + .expect("the changed project is valid"); + f.app = test_app(&f.store, &changed_project, f.mode.clone()); + + let assertion_path = format!("/v1/task-grants/{assertion_id}/assertion"); + let status_path = format!("/v1/task-grants/{status_id}/status"); + let assertion_status = request(&f, "POST", &assertion_path, &agent, false, None, None) + .await + .0; + let changed_status = request(&f, "GET", &status_path, &resource, false, None, None) + .await + .1; + assert_eq!(assertion_status, StatusCode::FORBIDDEN); + assert_eq!(changed_status["active"], false); + + f.app = original_app; + for grant_id in [assertion_id, status_id] { + let (_, restored_status) = request( + &f, + "GET", + &format!("/v1/task-grants/{grant_id}/status"), + &resource, + false, + None, + None, + ) + .await; + assert_eq!( + restored_status["active"], false, + "restoring the former role must not revive the grant" + ); + } + + f.admin + .batch_execute(&format!("DROP SCHEMA {} CASCADE", f.schema)) + .await + .unwrap(); +} + #[tokio::test] async fn task_approval_distinguishes_a_stale_holder_from_a_nonholder() { let f = fixture(900).await;